Skip to content

Commit

Permalink
Remove support for Python 3.7 and ugly async IO loop handling kludges…
Browse files Browse the repository at this point in the history
… required by it.
  • Loading branch information
aknrdureegaesr committed Jan 10, 2024
1 parent b96f05a commit 969b9a2
Show file tree
Hide file tree
Showing 7 changed files with 41 additions and 85 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
python: ['3.8', '3.9', '3.10', '3.11', '3.12']
image:
- ubuntu-latest
include:
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Bugfixes
for non-root SITE_URL, in particular when URL_TYPE is full_path.
(Issue #3715)

Nikola now requires Python 3.8 or newer.

For plugin developers
---------------------

Expand Down
2 changes: 1 addition & 1 deletion docs/manual.rst
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Obsolescence
You may say those are long term issues, or that they won't matter for years. Well,
I believe things should work forever, or as close to it as we can make them.
Nikola's static output and its input files will work as long as you can install
Python 3.7 or newer under Linux, Windows, or macOS and can find a server
Python 3.8 or newer under Linux, Windows, or macOS and can find a server
that sends files over HTTP. That's probably 10 or 15 years at least.

Also, static sites are easily handled by the Internet Archive.
Expand Down
21 changes: 11 additions & 10 deletions nikola/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-

# Copyright © 2012-2023 Roberto Alsina and others.
# Copyright © 2012-2024 Roberto Alsina and others.

# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
Expand All @@ -26,16 +26,10 @@

"""The main function of Nikola."""

import importlib.util
import os
import shutil
import sys
import textwrap
import traceback
import doit.cmd_base
from blinker import signal
from collections import defaultdict

from blinker import signal
import doit.cmd_base
from doit.cmd_base import TaskLoader2, _wrap
from doit.cmd_clean import Clean as DoitClean
from doit.cmd_completion import TabCompletion
Expand All @@ -45,6 +39,14 @@
from doit.loader import generate_tasks
from doit.reporter import ExecutedOnlyReporter

import importlib.util
import os
import shutil
import sys
import textwrap
import traceback
from typing import Any

from . import __version__
from .nikola import Nikola
from .plugin_categories import Command
Expand All @@ -56,7 +58,6 @@
except ImportError:
pass # This is only so raw_input/input does nicer things if it's available


config = {}

# DO NOT USE unless you know what you are doing!
Expand Down
28 changes: 5 additions & 23 deletions nikola/plugins/command/auto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,6 @@
REBUILDING_REFRESH_DELAY = 0.35
IDLE_REFRESH_DELAY = 0.05

if sys.platform == 'win32':
asyncio.set_event_loop(asyncio.ProactorEventLoop())


def base_path_from_siteuri(siteuri: str) -> str:
"""Extract the path part from a URI such as site['SITE_URL'].
Expand Down Expand Up @@ -275,27 +272,18 @@ def _execute(self, options, args):
self.wd_observer.schedule(ConfigEventHandler(_conf_fn, self.queue_rebuild, loop), _conf_dn, recursive=False)
self.wd_observer.start()

win_sleeper = None
# https://bugs.python.org/issue23057 (fixed in Python 3.8)
if sys.platform == 'win32' and sys.version_info < (3, 8):
win_sleeper = asyncio.ensure_future(windows_ctrlc_workaround())

if not self.has_server:
self.logger.info("Watching for changes...")
# Run the event loop forever (no server mode).
try:
# Run rebuild queue
loop.run_until_complete(self.run_rebuild_queue())

loop.run_forever()
except KeyboardInterrupt:
pass
finally:
if win_sleeper:
win_sleeper.cancel()
self.wd_observer.stop()
self.wd_observer.join()
loop.close()
return

if options['ipv6'] or '::' in host:
Expand Down Expand Up @@ -326,16 +314,17 @@ def _execute(self, options, args):
pass
finally:
self.logger.info("Server is shutting down.")
if win_sleeper:
win_sleeper.cancel()
if self.dns_sd:
self.dns_sd.Reset()
rebuild_queue_fut.cancel()
reload_queue_fut.cancel()

# Not sure why this isn't done by the web_runner.cleanup() code:
loop.run_until_complete(self.remove_websockets(None))

loop.run_until_complete(self.web_runner.cleanup())
self.wd_observer.stop()
self.wd_observer.join()
loop.close()

async def set_up_server(self, host: str, port: int, base_path: str, out_folder: str) -> None:
"""Set up aiohttp server and start it."""
Expand Down Expand Up @@ -482,7 +471,7 @@ async def websocket_handler(self, request):

return ws

async def remove_websockets(self, app) -> None:
async def remove_websockets(self, _app) -> None:
"""Remove all websockets."""
for ws in self.sockets:
await ws.close()
Expand Down Expand Up @@ -512,13 +501,6 @@ async def send_to_websockets(self, message: dict) -> None:
self.sockets.remove(ws)


async def windows_ctrlc_workaround() -> None:
"""Work around bpo-23057."""
# https://bugs.python.org/issue23057
while True:
await asyncio.sleep(1)


class IndexHtmlStaticResource(StaticResource):
"""A StaticResource implementation that serves /index.html in directory roots."""

Expand Down
7 changes: 3 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
# ########## platform specific stuff #############
if sys.version_info[0] == 2:
raise Exception('Python 2 is not supported')
elif sys.version_info[0] == 3 and sys.version_info[1] < 7:
raise Exception('Python 3 version < 3.7 is not supported')
elif sys.version_info[0] == 3 and sys.version_info[1] < 8:
raise Exception('Python 3 version < 3.8 is not supported')

##################################################

Expand Down Expand Up @@ -128,7 +128,6 @@ def run(self):
'Operating System :: POSIX',
'Operating System :: Unix',
'Programming Language :: Python',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
Expand All @@ -140,7 +139,7 @@ def run(self):
install_requires=dependencies,
extras_require=extras,
include_package_data=True,
python_requires='>=3.7',
python_requires='>=3.8',
cmdclass={'install': nikola_install, 'build_py': nikola_build_py},
data_files=[
('share/doc/nikola', [
Expand Down
64 changes: 18 additions & 46 deletions tests/integration/test_dev_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import pathlib
import requests
import socket
import sys
from typing import Optional, Tuple, Any, Dict

from ..helper import FakeSite
Expand Down Expand Up @@ -38,6 +37,7 @@ def find_unused_port() -> int:

class MyFakeSite(FakeSite):
def __init__(self, config: Dict[str, Any], configuration_filename="conf.py"):
super(MyFakeSite, self).__init__()
self.configured = True
self.debug = True
self.THEMES = []
Expand Down Expand Up @@ -70,13 +70,13 @@ def test_serves_root_dir(
test_was_successful = False
test_problem_description = "Async test setup apparently broken"
test_inner_error: Optional[BaseException] = None
loop_for_this_test = None
loop = None

async def grab_loop_and_run_test() -> None:
nonlocal test_problem_description, loop_for_this_test
nonlocal test_problem_description, loop

loop_for_this_test = asyncio.get_running_loop()
watchdog_handle = loop_for_this_test.call_later(TEST_MAX_DURATION, lambda: loop_for_this_test.stop())
loop = asyncio.get_running_loop()
watchdog_handle = loop.call_later(TEST_MAX_DURATION, loop.stop)
test_problem_description = f"Test did not complete within {TEST_MAX_DURATION} seconds."

def run_test() -> None:
Expand Down Expand Up @@ -113,13 +113,11 @@ def run_test() -> None:
LOGGER.info("Test completed successfully.")
else:
LOGGER.error("Test failed: %s", test_problem_description)
loop_for_this_test.call_soon_threadsafe(lambda: watchdog_handle.cancel())
loop.call_soon_threadsafe(watchdog_handle.cancel)
# Simulate Ctrl+C:
loop.call_soon_threadsafe(lambda: loop.call_later(0.01, loop.stop))

# We give the outer grab_loop_and_run_test a chance to complete
# before burning the bridge:
loop_for_this_test.call_soon_threadsafe(lambda: loop_for_this_test.call_later(0.05, lambda: loop_for_this_test.stop()))

await loop_for_this_test.run_in_executor(None, run_test)
await loop.run_in_executor(None, run_test)

# We defeat the nikola site building functionality, so this does not actually get called.
# But the code setting up site building wants a command list:
Expand All @@ -128,40 +126,14 @@ def run_test() -> None:
# Defeat the site building functionality, and instead insert the test:
command_auto.run_initial_rebuild = grab_loop_and_run_test

try:
# Start the development server
# which under the hood runs our test when trying to build the site:
command_auto.execute(options=options)

# Verify the test succeeded:
if test_inner_error is not None:
raise test_inner_error
assert test_was_successful, test_problem_description
finally:
# Nikola is written with the assumption that it can
# create the event loop at will without ever cleaning it up.
# As this tests runs several times in succession,
# that assumption becomes a problem.
LOGGER.info("Cleaning up loop.")
# Loop cleanup:
assert loop_for_this_test is not None
assert not loop_for_this_test.is_running()
loop_for_this_test.close()
asyncio.set_event_loop(None)
# We would like to leave it at that,
# but doing so causes the next test to fail.
#
# We did not find asyncio - API to reset the loop
# to "back to square one, as if just freshly started".
#
# The following code does not feel right, it's a kludge,
# but it apparently works for now:
if sys.platform == 'win32':
# For this case, the auto module has special code
# (at module load time! 😟) which we reluctantly reproduce here:
asyncio.set_event_loop(asyncio.ProactorEventLoop())
else:
asyncio.set_event_loop(asyncio.new_event_loop())
# Start the development server
# which under the hood runs our test when trying to build the site:
command_auto.execute(options=options)

# Verify the test succeeded:
if test_inner_error is not None:
raise test_inner_error
assert test_was_successful, test_problem_description


@pytest.fixture(scope="module",
Expand All @@ -184,7 +156,7 @@ def site_and_base_path(request) -> Tuple[MyFakeSite, str]:
"SITE_URL": request.param,
"OUTPUT_FOLDER": OUTPUT_FOLDER.as_posix(),
}
return (MyFakeSite(config), auto.base_path_from_siteuri(request.param))
return MyFakeSite(config), auto.base_path_from_siteuri(request.param)


@pytest.fixture(scope="module")
Expand Down

0 comments on commit 969b9a2

Please sign in to comment.