From 1eb12ed4891b17a5b1ce86c6e87e5457b2d9dc81 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Wed, 22 Jan 2025 22:52:14 +0500 Subject: [PATCH 01/13] add ssl support --- aiohttp_devtools/cli.py | 2 ++ aiohttp_devtools/runserver/config.py | 49 ++++++++++++++++++++++++---- aiohttp_devtools/runserver/main.py | 10 +++--- aiohttp_devtools/runserver/serve.py | 18 ++++++---- aiohttp_devtools/runserver/watch.py | 4 +-- main.py | 22 +++++++++++++ tests/test_runserver_config.py | 8 ++--- tests/test_runserver_main.py | 10 +++--- 8 files changed, 95 insertions(+), 28 deletions(-) create mode 100755 main.py diff --git a/aiohttp_devtools/cli.py b/aiohttp_devtools/cli.py index 37080cb5..09fa62fc 100644 --- a/aiohttp_devtools/cli.py +++ b/aiohttp_devtools/cli.py @@ -64,6 +64,7 @@ def serve(path: str, livereload: bool, bind_address: str, port: int, verbose: bo 'or just an instance of aiohttp.Application. env variable AIO_APP_FACTORY') port_help = 'Port to serve app from, default 8000. env variable: AIO_PORT' aux_port_help = 'Port to serve auxiliary app (reload and static) on, default port + 1. env variable: AIO_AUX_PORT' +ssl_context_factory_help = 'name of the ssl context factory to create ssl.SSLContext with' # defaults are all None here so default settings are defined in one place: DEV_DICT validation @@ -83,6 +84,7 @@ def serve(path: str, livereload: bool, bind_address: str, port: int, verbose: bo @click.option('-v', '--verbose', is_flag=True, help=verbose_help) @click.option("--browser-cache/--no-browser-cache", envvar="AIO_BROWSER_CACHE", default=None, help=browser_cache_help) +@click.option('--ssl-context-factory', 'ssl_context_factory_name', default=None, help=ssl_context_factory_help) @click.argument('project_args', nargs=-1) def runserver(**config: Any) -> None: """ diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 5c65b17d..63a46121 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -6,6 +6,7 @@ from typing import Awaitable, Callable, Optional, Union from aiohttp import web +import ssl import __main__ from ..exceptions import AiohttpDevConfigError as AdevConfigError @@ -43,9 +44,10 @@ def __init__(self, *, app_factory_name: Optional[str] = None, host: str = INFER_HOST, bind_address: str = "localhost", - main_port: int = 8000, + main_port: Optional[int] = None, aux_port: Optional[int] = None, - browser_cache: bool = False): + browser_cache: bool = False, + ssl_context_factory_name: Optional[str] = None): if root_path: self.root_path = Path(root_path).resolve() logger.debug('Root path specified: %s', self.root_path) @@ -83,9 +85,13 @@ def __init__(self, *, self.host = bind_address self.bind_address = bind_address + if main_port is None: + main_port = 8000 if ssl_context_factory_name == None else 8443 + self.protocol = 'http' self.main_port = main_port self.aux_port = aux_port or (main_port + 1) self.browser_cache = browser_cache + self.ssl_context_factory_name = ssl_context_factory_name logger.debug('config loaded:\n%s', self) @property @@ -135,15 +141,20 @@ def _resolve_path(self, _path: str, check: str, arg_name: str) -> Path: if not path.is_dir(): raise AdevConfigError('{} is not a directory'.format(path)) return path - - def import_app_factory(self) -> AppFactory: - """Import and return attribute/class from a python module. + + def import_module(self): + """Import and return python module. Raises: AdevConfigError - If the import failed. """ rel_py_file = self.py_file.relative_to(self.python_path) module_path = '.'.join(rel_py_file.with_suffix('').parts) + sys.path.insert(0, str(self.python_path)) + module = import_module(module_path) + # Rewrite the package name, so it will appear the same as running the app. + if module.__package__: + __main__.__package__ = module.__package__ sys.path.insert(0, str(self.python_path)) module = import_module(module_path) @@ -153,6 +164,16 @@ def import_app_factory(self) -> AppFactory: logger.debug('successfully loaded "%s" from "%s"', module_path, self.python_path) + self.watch_path = self.watch_path or Path(module.__file__ or ".").parent + return module + + def get_app_factory(self, module) -> AppFactory: + """Import and return attribute/class from a python module. + + Raises: + AdevConfigError - If the import failed. + """ + if self.app_factory_name is None: try: self.app_factory_name = next(an for an in APP_FACTORY_NAMES if hasattr(module, an)) @@ -179,8 +200,24 @@ def import_app_factory(self) -> AppFactory: raise AdevConfigError("'{}.{}' should not have required arguments.".format( self.py_file.name, self.app_factory_name)) - self.watch_path = self.watch_path or Path(module.__file__ or ".").parent return attr # type: ignore[no-any-return] + + def get_ssl_context(self, module) -> ssl.SSLContext: + if self.ssl_context_factory_name is None: + return None + else: + try: + attr = getattr(module, self.ssl_context_factory_name) + except AttributeError: + raise AdevConfigError("Module '{}' does not define a '{}' attribute/class".format( + self.py_file.name, self.ssl_context_factory_name)) + ssl_context = attr() + if isinstance(ssl_context, ssl.SSLContext): + self.protocol = 'https' + return ssl_context + else: + raise AdevConfigError("ssl-context-factory '{}' in module '{}' didn't return valid SSLContext".format( + self.ssl_context_factory_name, self.py_file.name)) async def load_app(self, app_factory: AppFactory) -> web.Application: if isinstance(app_factory, web.Application): diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py index 0dd35052..282086f1 100644 --- a/aiohttp_devtools/runserver/main.py +++ b/aiohttp_devtools/runserver/main.py @@ -29,9 +29,11 @@ def runserver(**config_kwargs: Any) -> RunServer: """ # force a full reload in sub processes so they load an updated version of code, this must be called only once set_start_method('spawn') - config = Config(**config_kwargs) - config.import_app_factory() + module = config.import_module() + ssl_context = config.get_ssl_context(module) + # config.get_app_factory(module) + # config.get_ssl_context_factory(module) asyncio.run(check_port_open(config.main_port, host=config.bind_address)) @@ -49,7 +51,7 @@ def runserver(**config_kwargs: Any) -> RunServer: logger.debug('starting livereload to watch %s', config.static_path_str) aux_app.cleanup_ctx.append(static_manager.cleanup_ctx) - url = 'http://{0.host}:{0.aux_port}'.format(config) + url = '{0.protocol}://{0.host}:{0.aux_port}'.format(config) logger.info('Starting aux server at %s ◆', url) if config.static_path: @@ -57,7 +59,7 @@ def runserver(**config_kwargs: Any) -> RunServer: logger.info('serving static files from ./%s/ at %s%s', rel_path, url, config.static_url) return {"app": aux_app, "host": config.bind_address, "port": config.aux_port, - "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger} + "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger, "ssl_context": ssl_context} def serve_static(*, static_path: str, livereload: bool = True, bind_address: str = "localhost", port: int = 8000, diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index 9be8e6ba..f82c3d41 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -25,6 +25,8 @@ from .log_handlers import AccessLogger from .utils import MutableValue +import ssl + try: from aiohttp_jinja2 import static_root_key except ImportError: @@ -103,7 +105,7 @@ async def no_cache_middleware(request: web.Request, handler: Handler) -> web.Str # we set the app key even in middleware to make the switch to production easier and for backwards compat. @web.middleware async def static_middleware(request: web.Request, handler: Handler) -> web.StreamResponse: - static_url = 'http://{}:{}/{}'.format(get_host(request), config.aux_port, static_path) + static_url = '{}://{}:{}/{}'.format(config.protocol, get_host(request), config.aux_port, static_path) dft_logger.debug('setting app static_root_url to "%s"', static_url) _change_static_url(request.app, static_url) return await handler(request) @@ -120,10 +122,10 @@ def shutdown() -> NoReturn: path = config.path_prefix + "/shutdown" app.router.add_route("GET", path, do_shutdown, name="_devtools.shutdown") - dft_logger.debug("Created shutdown endpoint at http://{}:{}{}".format(config.host, config.main_port, path)) + dft_logger.debug("Created shutdown endpoint at {}://{}:{}{}".format(config.protocol, config.host, config.main_port, path)) if config.static_path is not None: - static_url = 'http://{}:{}/{}'.format(config.host, config.aux_port, static_path) + static_url = '{}://{}:{}/{}'.format(config.protocol, config.host, config.aux_port, static_path) dft_logger.debug('settings app static_root_url to "%s"', static_url) _set_static_url(app, static_url) @@ -164,7 +166,9 @@ def set_tty(tty_path: Optional[str]) -> Iterator[None]: def serve_main_app(config: Config, tty_path: Optional[str]) -> None: with set_tty(tty_path): setup_logging(config.verbose) - app_factory = config.import_app_factory() + module = config.import_module() + app_factory = config.get_app_factory(module) + ssl_context = config.get_ssl_context(module) if sys.version_info >= (3, 11): with asyncio.Runner() as runner: app_runner = runner.run(create_main_app(config, app_factory)) @@ -180,7 +184,7 @@ def serve_main_app(config: Config, tty_path: Optional[str]) -> None: loop = asyncio.new_event_loop() runner = loop.run_until_complete(create_main_app(config, app_factory)) try: - loop.run_until_complete(start_main_app(runner, config.bind_address, config.main_port)) + loop.run_until_complete(start_main_app(runner, config.bind_address, config.main_port, ssl_context)) loop.run_forever() except KeyboardInterrupt: # pragma: no cover pass @@ -197,9 +201,9 @@ async def create_main_app(config: Config, app_factory: AppFactory) -> web.AppRun return web.AppRunner(app, access_log_class=AccessLogger, shutdown_timeout=0.1) -async def start_main_app(runner: web.AppRunner, host: str, port: int) -> None: +async def start_main_app(runner: web.AppRunner, host: str, port: int, ssl_context: ssl.SSLContext) -> None: await runner.setup() - site = web.TCPSite(runner, host=host, port=port) + site = web.TCPSite(runner, host=host, port=port, ssl_context=ssl_context) await site.start() diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py index 07352397..5f575c9f 100644 --- a/aiohttp_devtools/runserver/watch.py +++ b/aiohttp_devtools/runserver/watch.py @@ -107,7 +107,7 @@ async def _src_reload_when_live(self, checks: int) -> None: assert self._app is not None and self._session is not None if self._app[WS]: - url = "http://{0.host}:{0.main_port}/?_checking_alive=1".format(self._config) + url = "{0.protocol}}://{0.host}:{0.main_port}/?_checking_alive=1".format(self._config) logger.debug('checking app at "%s" is running before prompting reload...', url) for i in range(checks): await asyncio.sleep(0.1) @@ -123,7 +123,7 @@ async def _src_reload_when_live(self, checks: int) -> None: def _start_dev_server(self) -> None: act = 'Start' if self._reloads == 0 else 'Restart' - logger.info('%sing dev server at http://%s:%s ●', act, self._config.host, self._config.main_port) + logger.info('%sing dev server at %s://%s:%s ●', act, self._config.protocol, self._config.host, self._config.main_port) try: tty_path = os.ttyname(sys.stdin.fileno()) diff --git a/main.py b/main.py new file mode 100755 index 00000000..438e5c88 --- /dev/null +++ b/main.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +from aiohttp import web +from aiohttp_devtools import cli +import ssl + +async def hello(request): + return web.Response(text="

hello world

", content_type="text/html") + +async def create_app(): + a = web.Application() + a.router.add_get("/", hello) + return a + +def get_ssl_context(): + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain('/home/roman/Projects/SemanticStoreSuite/SSArchive/SS_PWA&UI_Resarch/certs/server.crt', + '/home/roman/Projects/SemanticStoreSuite/SSArchive/SS_PWA&UI_Resarch/certs/server.key') + return ssl_context + # return False + +if __name__ == '__main__': + cli.cli() \ No newline at end of file diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py index 5b22747a..ec9c2ce3 100644 --- a/tests/test_runserver_config.py +++ b/tests/test_runserver_config.py @@ -36,7 +36,7 @@ async def test_create_app_wrong_name(tmpworkdir): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path='app.py', app_factory_name='missing') with pytest.raises(AiohttpDevConfigError) as excinfo: - config.import_app_factory() + config.get_app_factory() assert excinfo.value.args[0] == "Module 'app.py' does not define a 'missing' attribute/class" @@ -56,7 +56,7 @@ async def app_factory(): """ }) config = Config(app_path='app.py') - app = await config.load_app(config.import_app_factory()) + app = await config.load_app(config.get_app_factory()) assert isinstance(app, web.Application) @@ -71,7 +71,7 @@ def app_factory(): config = Config(app_path='app.py') with pytest.raises(AiohttpDevConfigError, match=r"'app_factory' returned 'int' not an aiohttp\.web\.Application"): - await config.load_app(config.import_app_factory()) + await config.load_app(config.get_app_factory()) @forked @@ -85,4 +85,4 @@ def app_factory(foo): config = Config(app_path='app.py') with pytest.raises(AiohttpDevConfigError, match=r"'app\.py\.app_factory' should not have required arguments"): - await config.load_app(config.import_app_factory()) + await config.load_app(config.get_app_factory()) diff --git a/tests/test_runserver_main.py b/tests/test_runserver_main.py index 2f0d9a9e..6368352b 100644 --- a/tests/test_runserver_main.py +++ b/tests/test_runserver_main.py @@ -145,7 +145,7 @@ async def create_app(): set_start_method("spawn") config = Config(app_path="app.py", root_path=tmpworkdir, main_port=0, app_factory_name="create_app") - config.import_app_factory() + config.get_app_factory() app_task = AppTask(config) app_task._start_dev_server() @@ -162,7 +162,7 @@ async def create_app(): async def test_run_app_aiohttp_client(tmpworkdir, aiohttp_client): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path='app.py') - app_factory = config.import_app_factory() + app_factory = config.get_app_factory() app = await config.load_app(app_factory) modify_main_app(app, config) assert isinstance(app, aiohttp.web.Application) @@ -178,7 +178,7 @@ async def test_run_app_aiohttp_client(tmpworkdir, aiohttp_client): async def test_run_app_browser_cache(tmpworkdir, aiohttp_client): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path="app.py", browser_cache=True) - app_factory = config.import_app_factory() + app_factory = config.get_app_factory() app = await config.load_app(app_factory) modify_main_app(app, config) cli = await aiohttp_client(app) @@ -208,7 +208,7 @@ async def test_serve_main_app(tmpworkdir, mocker): loop.call_later(0.5, loop.stop) config = Config(app_path="app.py", main_port=0) - runner = await create_main_app(config, config.import_app_factory()) + runner = await create_main_app(config, config.get_app_factory()) await start_main_app(runner, config.bind_address, config.main_port) mock_modify_main_app.assert_called_with(mock.ANY, config) @@ -232,7 +232,7 @@ async def hello(request): mock_modify_main_app = mocker.patch('aiohttp_devtools.runserver.serve.modify_main_app') config = Config(app_path="app.py", main_port=0) - runner = await create_main_app(config, config.import_app_factory()) + runner = await create_main_app(config, config.get_app_factory()) await start_main_app(runner, config.bind_address, config.main_port) mock_modify_main_app.assert_called_with(mock.ANY, config) From cfd5241b4ae7c8a8abf3507ce6e505986d63b500 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Thu, 23 Jan 2025 00:39:35 +0500 Subject: [PATCH 02/13] tests revision --- aiohttp_devtools/runserver/watch.py | 2 +- main.py | 22 ---------------------- tests/test_runserver_config.py | 12 ++++++++---- tests/test_runserver_main.py | 19 ++++++++++++------- tests/test_runserver_watch.py | 2 ++ 5 files changed, 23 insertions(+), 34 deletions(-) delete mode 100755 main.py diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py index 5f575c9f..84125721 100644 --- a/aiohttp_devtools/runserver/watch.py +++ b/aiohttp_devtools/runserver/watch.py @@ -107,7 +107,7 @@ async def _src_reload_when_live(self, checks: int) -> None: assert self._app is not None and self._session is not None if self._app[WS]: - url = "{0.protocol}}://{0.host}:{0.main_port}/?_checking_alive=1".format(self._config) + url = "{0.protocol}://{0.host}:{0.main_port}/?_checking_alive=1".format(self._config) logger.debug('checking app at "%s" is running before prompting reload...', url) for i in range(checks): await asyncio.sleep(0.1) diff --git a/main.py b/main.py deleted file mode 100755 index 438e5c88..00000000 --- a/main.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -from aiohttp import web -from aiohttp_devtools import cli -import ssl - -async def hello(request): - return web.Response(text="

hello world

", content_type="text/html") - -async def create_app(): - a = web.Application() - a.router.add_get("/", hello) - return a - -def get_ssl_context(): - ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain('/home/roman/Projects/SemanticStoreSuite/SSArchive/SS_PWA&UI_Resarch/certs/server.crt', - '/home/roman/Projects/SemanticStoreSuite/SSArchive/SS_PWA&UI_Resarch/certs/server.key') - return ssl_context - # return False - -if __name__ == '__main__': - cli.cli() \ No newline at end of file diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py index ec9c2ce3..a719adf8 100644 --- a/tests/test_runserver_config.py +++ b/tests/test_runserver_config.py @@ -36,7 +36,8 @@ async def test_create_app_wrong_name(tmpworkdir): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path='app.py', app_factory_name='missing') with pytest.raises(AiohttpDevConfigError) as excinfo: - config.get_app_factory() + module = config.import_module + config.get_app_factory(module) assert excinfo.value.args[0] == "Module 'app.py' does not define a 'missing' attribute/class" @@ -56,7 +57,8 @@ async def app_factory(): """ }) config = Config(app_path='app.py') - app = await config.load_app(config.get_app_factory()) + module = config.import_module() + app = await config.load_app(config.get_app_factory(module)) assert isinstance(app, web.Application) @@ -69,9 +71,10 @@ def app_factory(): """ }) config = Config(app_path='app.py') + module = config.import_module() with pytest.raises(AiohttpDevConfigError, match=r"'app_factory' returned 'int' not an aiohttp\.web\.Application"): - await config.load_app(config.get_app_factory()) + await config.load_app(config.get_app_factory(module)) @forked @@ -83,6 +86,7 @@ def app_factory(foo): """ }) config = Config(app_path='app.py') + module = config.import_module() with pytest.raises(AiohttpDevConfigError, match=r"'app\.py\.app_factory' should not have required arguments"): - await config.load_app(config.get_app_factory()) + await config.load_app(config.get_app_factory(module)) diff --git a/tests/test_runserver_main.py b/tests/test_runserver_main.py index 6368352b..a2f4602f 100644 --- a/tests/test_runserver_main.py +++ b/tests/test_runserver_main.py @@ -145,7 +145,8 @@ async def create_app(): set_start_method("spawn") config = Config(app_path="app.py", root_path=tmpworkdir, main_port=0, app_factory_name="create_app") - config.get_app_factory() + module = config.import_module() + config.get_app_factory(module) app_task = AppTask(config) app_task._start_dev_server() @@ -162,7 +163,8 @@ async def create_app(): async def test_run_app_aiohttp_client(tmpworkdir, aiohttp_client): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path='app.py') - app_factory = config.get_app_factory() + module = config.import_module() + app_factory = config.get_app_factory(module) app = await config.load_app(app_factory) modify_main_app(app, config) assert isinstance(app, aiohttp.web.Application) @@ -178,7 +180,8 @@ async def test_run_app_aiohttp_client(tmpworkdir, aiohttp_client): async def test_run_app_browser_cache(tmpworkdir, aiohttp_client): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path="app.py", browser_cache=True) - app_factory = config.get_app_factory() + module = config.import_module() + app_factory = config.get_app_factory(module) app = await config.load_app(app_factory) modify_main_app(app, config) cli = await aiohttp_client(app) @@ -208,8 +211,9 @@ async def test_serve_main_app(tmpworkdir, mocker): loop.call_later(0.5, loop.stop) config = Config(app_path="app.py", main_port=0) - runner = await create_main_app(config, config.get_app_factory()) - await start_main_app(runner, config.bind_address, config.main_port) + module = config.import_module() + runner = await create_main_app(config, config.get_app_factory(module)) + await start_main_app(runner, config.bind_address, config.main_port, None) mock_modify_main_app.assert_called_with(mock.ANY, config) @@ -232,8 +236,9 @@ async def hello(request): mock_modify_main_app = mocker.patch('aiohttp_devtools.runserver.serve.modify_main_app') config = Config(app_path="app.py", main_port=0) - runner = await create_main_app(config, config.get_app_factory()) - await start_main_app(runner, config.bind_address, config.main_port) + module = config.import_module() + runner = await create_main_app(config, config.get_app_factory(module)) + await start_main_app(runner, config.bind_address, config.main_port, None) mock_modify_main_app.assert_called_with(mock.ANY, config) diff --git a/tests/test_runserver_watch.py b/tests/test_runserver_watch.py index b8373d68..d733b97e 100644 --- a/tests/test_runserver_watch.py +++ b/tests/test_runserver_watch.py @@ -77,6 +77,7 @@ async def test_python_no_server(mocker): config = MagicMock() config.main_port = 8000 + config.protocol = 'http' app_task = AppTask(config) start_mock = mocker.patch.object(app_task, "_start_dev_server", autospec=True) stop_mock = mocker.patch.object(app_task, "_stop_dev_server", autospec=True) @@ -109,6 +110,7 @@ async def test_reload_server_running(aiohttp_client, mocker): config = MagicMock() config.host = "localhost" config.main_port = cli.server.port + config.protocol = 'http' app_task = AppTask(config) app_task._app = app From 9f26d69ce55a367301a9b939aa36cbd7ad77d442 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Thu, 23 Jan 2025 01:20:53 +0500 Subject: [PATCH 03/13] readme revision --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 40e4bf63..d35a3dd4 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,8 @@ or ``main.py``) or to a specific file. The ``--app-factory`` option can be used from the app path file, if not supplied some default method names are tried (namely `app`, `app_factory`, `get_app` and `create_app`, which can be variables, functions, or coroutines). +The ``--ssl-context-factory`` option can be used to define method from the app path file, which returns ssl.SSLContext +for ssl support. All ``runserver`` arguments can be set via environment variables. From adfefdf4700d2477f97c2184ebf618898c3ca788 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Fri, 24 Jan 2025 11:37:00 +0500 Subject: [PATCH 04/13] amendment --- aiohttp_devtools/runserver/config.py | 16 ++++++---------- aiohttp_devtools/runserver/main.py | 2 -- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 63a46121..5d433209 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -3,7 +3,7 @@ import sys from importlib import import_module from pathlib import Path -from typing import Awaitable, Callable, Optional, Union +from typing import Awaitable, Callable, Optional, Union, Literal from aiohttp import web import ssl @@ -87,12 +87,15 @@ def __init__(self, *, self.bind_address = bind_address if main_port is None: main_port = 8000 if ssl_context_factory_name == None else 8443 - self.protocol = 'http' self.main_port = main_port self.aux_port = aux_port or (main_port + 1) self.browser_cache = browser_cache self.ssl_context_factory_name = ssl_context_factory_name logger.debug('config loaded:\n%s', self) + + @property + def protocol(self) -> Literal["http", "https"]: + return "http" if self.ssl_context_factory_name is None else "https" @property def static_path_str(self) -> Optional[str]: @@ -156,19 +159,13 @@ def import_module(self): if module.__package__: __main__.__package__ = module.__package__ - sys.path.insert(0, str(self.python_path)) - module = import_module(module_path) - # Rewrite the package name, so it will appear the same as running the app. - if module.__package__: - __main__.__package__ = module.__package__ - logger.debug('successfully loaded "%s" from "%s"', module_path, self.python_path) self.watch_path = self.watch_path or Path(module.__file__ or ".").parent return module def get_app_factory(self, module) -> AppFactory: - """Import and return attribute/class from a python module. + """Return attribute/class from a python module. Raises: AdevConfigError - If the import failed. @@ -213,7 +210,6 @@ def get_ssl_context(self, module) -> ssl.SSLContext: self.py_file.name, self.ssl_context_factory_name)) ssl_context = attr() if isinstance(ssl_context, ssl.SSLContext): - self.protocol = 'https' return ssl_context else: raise AdevConfigError("ssl-context-factory '{}' in module '{}' didn't return valid SSLContext".format( diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py index 282086f1..a919e7ef 100644 --- a/aiohttp_devtools/runserver/main.py +++ b/aiohttp_devtools/runserver/main.py @@ -32,8 +32,6 @@ def runserver(**config_kwargs: Any) -> RunServer: config = Config(**config_kwargs) module = config.import_module() ssl_context = config.get_ssl_context(module) - # config.get_app_factory(module) - # config.get_ssl_context_factory(module) asyncio.run(check_port_open(config.main_port, host=config.bind_address)) From d3cbee43c3e0e85a73f235c8c00bfe2d6246c4f3 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Sat, 25 Jan 2025 00:02:46 +0500 Subject: [PATCH 05/13] linter and test failures correction --- aiohttp_devtools/runserver/config.py | 11 ++++++----- aiohttp_devtools/runserver/main.py | 7 +++++-- aiohttp_devtools/runserver/serve.py | 8 ++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 5d433209..5bfb955e 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -4,9 +4,10 @@ from importlib import import_module from pathlib import Path from typing import Awaitable, Callable, Optional, Union, Literal +from types import ModuleType from aiohttp import web -import ssl +from ssl import SSLContext import __main__ from ..exceptions import AiohttpDevConfigError as AdevConfigError @@ -145,7 +146,7 @@ def _resolve_path(self, _path: str, check: str, arg_name: str) -> Path: raise AdevConfigError('{} is not a directory'.format(path)) return path - def import_module(self): + def import_module(self) -> ModuleType: """Import and return python module. Raises: @@ -164,7 +165,7 @@ def import_module(self): self.watch_path = self.watch_path or Path(module.__file__ or ".").parent return module - def get_app_factory(self, module) -> AppFactory: + def get_app_factory(self, module: ModuleType) -> AppFactory: """Return attribute/class from a python module. Raises: @@ -199,7 +200,7 @@ def get_app_factory(self, module) -> AppFactory: return attr # type: ignore[no-any-return] - def get_ssl_context(self, module) -> ssl.SSLContext: + def get_ssl_context(self, module: ModuleType) -> Union[SSLContext, None]: if self.ssl_context_factory_name is None: return None else: @@ -209,7 +210,7 @@ def get_ssl_context(self, module) -> ssl.SSLContext: raise AdevConfigError("Module '{}' does not define a '{}' attribute/class".format( self.py_file.name, self.ssl_context_factory_name)) ssl_context = attr() - if isinstance(ssl_context, ssl.SSLContext): + if isinstance(ssl_context, SSLContext): return ssl_context else: raise AdevConfigError("ssl-context-factory '{}' in module '{}' didn't return valid SSLContext".format( diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py index a919e7ef..69fee106 100644 --- a/aiohttp_devtools/runserver/main.py +++ b/aiohttp_devtools/runserver/main.py @@ -1,7 +1,7 @@ import asyncio import os from multiprocessing import set_start_method -from typing import Any, Type, TypedDict +from typing import Any, Type, TypedDict, Union from aiohttp.abc import AbstractAccessLogger from aiohttp.web import Application @@ -11,6 +11,7 @@ from .log_handlers import AuxAccessLogger from .serve import check_port_open, create_auxiliary_app from .watch import AppTask, LiveReloadTask +from ssl import SSLContext class RunServer(TypedDict): @@ -19,6 +20,8 @@ class RunServer(TypedDict): port: int shutdown_timeout: float access_log_class: Type[AbstractAccessLogger] + ssl_context: Union[SSLContext, None] + def runserver(**config_kwargs: Any) -> RunServer: @@ -75,4 +78,4 @@ def serve_static(*, static_path: str, livereload: bool = True, bind_address: str livereload_status = 'ON' if livereload else 'OFF' logger.info('Serving "%s" at http://%s:%d, livereload %s', static_path, bind_address, port, livereload_status) return {"app": app, "host": bind_address, "port": port, - "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger} + "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger, "ssl_context": None} diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index f82c3d41..0b921902 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -7,7 +7,7 @@ import warnings from errno import EADDRINUSE from pathlib import Path -from typing import Any, Iterator, List, NoReturn, Optional, Set, Tuple +from typing import Any, Iterator, List, NoReturn, Optional, Set, Tuple, Union from aiohttp import WSMsgType, web from aiohttp.hdrs import LAST_MODIFIED, CONTENT_LENGTH @@ -25,7 +25,7 @@ from .log_handlers import AccessLogger from .utils import MutableValue -import ssl +from ssl import SSLContext try: from aiohttp_jinja2 import static_root_key @@ -173,7 +173,7 @@ def serve_main_app(config: Config, tty_path: Optional[str]) -> None: with asyncio.Runner() as runner: app_runner = runner.run(create_main_app(config, app_factory)) try: - runner.run(start_main_app(app_runner, config.bind_address, config.main_port)) + runner.run(start_main_app(app_runner, config.bind_address, config.main_port, ssl_context)) runner.get_loop().run_forever() except KeyboardInterrupt: pass @@ -201,7 +201,7 @@ async def create_main_app(config: Config, app_factory: AppFactory) -> web.AppRun return web.AppRunner(app, access_log_class=AccessLogger, shutdown_timeout=0.1) -async def start_main_app(runner: web.AppRunner, host: str, port: int, ssl_context: ssl.SSLContext) -> None: +async def start_main_app(runner: web.AppRunner, host: str, port: int, ssl_context: Union[SSLContext, None]) -> None: await runner.setup() site = web.TCPSite(runner, host=host, port=port, ssl_context=ssl_context) await site.start() From f90bdaddd08e59cbef886f8d1998137c653c299f Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Mon, 27 Jan 2025 23:53:32 +0500 Subject: [PATCH 06/13] livereload.js html src protocol bug, test_create_app_wrong_name bug, test_runserver_with_ssl, pytest-datafiles requirements plugin --- aiohttp_devtools/runserver/serve.py | 4 +- requirements.txt | 1 + tests/test_certs/server.crt | 30 ++++++++ tests/test_certs/server.key | 51 +++++++++++++ tests/test_runserver_config.py | 2 +- tests/test_runserver_with_ssl.py | 109 ++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 tests/test_certs/server.crt create mode 100644 tests/test_certs/server.key create mode 100644 tests/test_runserver_with_ssl.py diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index 0b921902..42abe01d 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -32,7 +32,7 @@ except ImportError: static_root_key = None # type: ignore[assignment] -LIVE_RELOAD_HOST_SNIPPET = '\n\n' +LIVE_RELOAD_HOST_SNIPPET = '\n\n' LIVE_RELOAD_LOCAL_SNIPPET = b'\n\n' LAST_RELOAD = web.AppKey("LAST_RELOAD", List[float]) @@ -84,7 +84,7 @@ async def on_prepare(request: web.Request, response: web.StreamResponse) -> None or request.path.startswith("/_debugtoolbar") or "text/html" not in response.content_type): return - lr_snippet = LIVE_RELOAD_HOST_SNIPPET.format(get_host(request), config.aux_port) + lr_snippet = LIVE_RELOAD_HOST_SNIPPET.format(config.protocol, get_host(request), config.aux_port) dft_logger.debug("appending live reload snippet '%s' to body", lr_snippet) response.body += lr_snippet.encode() response.headers[CONTENT_LENGTH] = str(len(response.body)) diff --git a/requirements.txt b/requirements.txt index 86346b5d..da600ab5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ pytest-mock==3.14.0 pytest-sugar==1.0.0 pytest-timeout==2.2.0 pytest-toolbox==0.4 +pytest-datafiles==3.0.0 watchfiles==0.21.0 diff --git a/tests/test_certs/server.crt b/tests/test_certs/server.crt new file mode 100644 index 00000000..4bd9edbc --- /dev/null +++ b/tests/test_certs/server.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFOjCCAyICFCvF0YymuYiohdstQyrHzWdMOa5gMA0GCSqGSIb3DQEBCwUAMFox +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQxEzARBgNVBAMMClRlc3RSb290Q0EwHhcNMjUw +MTI2MTIyMzIyWhcNMzUwMTI0MTIyMzIyWjBZMQswCQYDVQQGEwJBVTETMBEGA1UE +CAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRk +MRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDI5oxS4wjneH21uhBj3bnkVQCTntieysO28zMKJdA8M/LVI6NtX9zJGIiR +Oum00ZmN1ESNIgXXscyeeQuFaK7CNc6JFVMOXoUBukWHhdA3IXotAoS0+6Nt8rc2 +joyQzembHuA2BQxHhF8gXXKhW6hk0vAjBjpYLGusxuVOgbvBKzL1VXNblSVGaBUm +xUZ9oZnGJw0HeBphDicGjJEokJMDe70vs9wlZdPDxDy/8iyFf+dPtnfCR1v2wzcT +vxI0lRqcf8n5k2cGAZKsE268/PNKbTyR5J7xqRe9hMhnEdCvxkWLhwQcwTKU1a2H +zXii3zZBh0MkcosZ8PmG/JtTSQRKFFGBa7aFh5oVuw//Kdm8qSEqrEeTXB9Us1eB +OS+kFTb/630kEuvLOc1gB3KcLw43AWLc5u5jzxyEcI6yc6wRxQTcxhEIfbj9tLEe +H9aw4nIsSa7lcOZXVboF5i1XrOC+KeAUPAxRjqttjlxAToZtIOtVPIhnjh1iVAP7 +g+Y6iGlc1t1jWN2IJrnlf7NyX98Uf5pr98O2NwyCcz0rpZPxHdLNE6/Wk2EugQJ9 +fNTEDn9rYW1iw1VMETZ/A53kCOIvse/KxS6aoWq4iPtfgzS3928DN7fZ5wJ5rYuv +pHBzzsFqkY+Oy341s91LIq6ZImTaIWd22KjU1hqdu+2MlCWPTQIDAQABMA0GCSqG +SIb3DQEBCwUAA4ICAQBEpPAoiWFX6st160wz/wJLqlgrr53iQwGyP/CttTE/LNHa +g+bVeJ14fsnwk47+DFxJbWuo3YipVEaIXXqdI2BUgNZLUrfBNGIdq4G0K1KcNeQf +O+Qql5he8LV9TKHj9N6efoZbQFWXixhkJwzb08XVEfWwUt4rDbFWLfEKLMpucRGw +1E5hB/92HuM9yB7ao5sXsMNddvlS4wVLThIw5pr/170nB3uHQXTVnAif0301SMk/ +i4wD7wevC9gz+40zbyC2HSsKhS2s+Jjey0/nSack8l5dISMp1XCJweG51Vb8F9Ml +5JZWdlw9J6cbJhw++oOBktLMCmnTiTP67aYlJhgrQeyPQXcg2uYDNlsK5nPqFxWZ +qjdvB6FMI9wS7LJylI9wJHDcG18+U8LrYnDIlotN2OJE7RVP/fYsAHCSswBwl0kH +3y1xIthILUSL1vCUXIZcI6hYSkxGlxTSd4KQoioVHr4/uavIIJxtf1dfkGRvVAEu +vo92OIiXpZ6Rhf4WUXaV6/kB9Jwkj0lJZ2RUw/CSVS8v3g4m4d09TsrhxKKmZxz7 +m+vsVopyBewXHHXKcmzI1OO5hL1wyjSx1TIAlr9MW3o3umvSgh3hM3Ildcmni8Xt +tUa95NKvjruz8UJ8gE50TZgsJI4ywT1QSyC55bfv74kKzHysv6zAj19y2CE0Hg== +-----END CERTIFICATE----- diff --git a/tests/test_certs/server.key b/tests/test_certs/server.key new file mode 100644 index 00000000..f4255586 --- /dev/null +++ b/tests/test_certs/server.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py index a719adf8..3e837e64 100644 --- a/tests/test_runserver_config.py +++ b/tests/test_runserver_config.py @@ -36,7 +36,7 @@ async def test_create_app_wrong_name(tmpworkdir): mktree(tmpworkdir, SIMPLE_APP) config = Config(app_path='app.py', app_factory_name='missing') with pytest.raises(AiohttpDevConfigError) as excinfo: - module = config.import_module + module = config.import_module() config.get_app_factory(module) assert excinfo.value.args[0] == "Module 'app.py' does not define a 'missing' attribute/class" diff --git a/tests/test_runserver_with_ssl.py b/tests/test_runserver_with_ssl.py new file mode 100644 index 00000000..027e0bb6 --- /dev/null +++ b/tests/test_runserver_with_ssl.py @@ -0,0 +1,109 @@ +import asyncio +import json +from unittest import mock + +import aiohttp +import pytest +from aiohttp import ClientTimeout +from pytest_toolbox import mktree +import ssl + +from aiohttp_devtools.runserver import runserver +from aiohttp_devtools.runserver.config import Config + +from aiohttp_devtools.exceptions import AiohttpDevConfigError + + +from .conftest import forked + + +async def test_load_invalid_app(tmpworkdir): + mktree(tmpworkdir, { + 'invalid': "it's not python file)" + }) + with pytest.raises(AiohttpDevConfigError): + Config(app_path='invalid') + +async def check_server_running(check_callback, sslcontext): + port_open = False + async with aiohttp.ClientSession(timeout=ClientTimeout(total=1)) as session: + for i in range(50): # pragma: no branch + try: + async with session.get('https://localhost:8443/', ssl=sslcontext): + pass + except OSError: + await asyncio.sleep(0.1) + else: + port_open = True + break + assert port_open + await check_callback(session, sslcontext) + await asyncio.sleep(.25) # TODO(aiohttp 4): Remove this hack + +@pytest.mark.filterwarnings(r"ignore:unclosed:ResourceWarning") +@forked +@pytest.mark.datafiles('tests/test_certs', keep_top_dir = True) +def test_start_runserver(datafiles, tmpworkdir, smart_caplog): + mktree(tmpworkdir, { + 'app.py': """\ +from aiohttp import web +import ssl +async def hello(request): + return web.Response(text='

hello world

', content_type='text/html') + +async def has_error(request): + raise ValueError() + +def create_app(): + app = web.Application() + app.router.add_get('/', hello) + app.router.add_get('/error', has_error) + return app + +def get_ssl_context(): + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain('test_certs/server.crt', 'test_certs/server.key') + return ssl_context + """, + 'static_dir/foo.js': 'var bar=1;', + }) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + args = runserver(app_path="app.py", static_path="static_dir", bind_address="0.0.0.0", ssl_context_factory_name='get_ssl_context') + aux_app = args["app"] + aux_port = args["port"] + runapp_host = args["host"] + assert isinstance(aux_app, aiohttp.web.Application) + assert aux_port == 8444 + assert runapp_host == "0.0.0.0" + for startup in aux_app.on_startup: + loop.run_until_complete(startup(aux_app)) + sslcontext = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + async def check_callback(session, sslcontext): + print(session, sslcontext) + async with session.get('https://localhost:8443/', ssl=sslcontext) as r: + assert r.status == 200 + assert r.headers['content-type'].startswith('text/html') + text = await r.text() + print(text) + assert '

hello world

' in text + assert '' in text + + async with session.get('https://localhost:8443/error', ssl=sslcontext) as r: + assert r.status == 500 + assert 'raise ValueError()' in (await r.text()) + + try: + loop.run_until_complete(check_server_running(check_callback, sslcontext)) + finally: + for shutdown in aux_app.on_shutdown: + loop.run_until_complete(shutdown(aux_app)) + loop.run_until_complete(aux_app.cleanup()) + assert ( + 'adev.server.dft INFO: Starting aux server at https://localhost:8444 ◆\n' + 'adev.server.dft INFO: serving static files from ./static_dir/ at https://localhost:8444/static/\n' + 'adev.server.dft INFO: Starting dev server at https://localhost:8443 ●\n' + ) in smart_caplog + loop.run_until_complete(asyncio.sleep(.25)) # TODO(aiohttp 4): Remove this hack + + From fd8e41ba4e24925c4e4da01687d238f3a09e52a7 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Mon, 27 Jan 2025 23:59:25 +0500 Subject: [PATCH 07/13] minor changes --- tests/test_runserver_with_ssl.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_runserver_with_ssl.py b/tests/test_runserver_with_ssl.py index 027e0bb6..dc390564 100644 --- a/tests/test_runserver_with_ssl.py +++ b/tests/test_runserver_with_ssl.py @@ -1,6 +1,4 @@ import asyncio -import json -from unittest import mock import aiohttp import pytest @@ -13,7 +11,6 @@ from aiohttp_devtools.exceptions import AiohttpDevConfigError - from .conftest import forked From 74891d3304337812459550357f474e82b62923aa Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Tue, 28 Jan 2025 00:28:57 +0500 Subject: [PATCH 08/13] rename to test_start_runserver_ssl --- tests/test_runserver_with_ssl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_runserver_with_ssl.py b/tests/test_runserver_with_ssl.py index dc390564..78dacd63 100644 --- a/tests/test_runserver_with_ssl.py +++ b/tests/test_runserver_with_ssl.py @@ -40,7 +40,7 @@ async def check_server_running(check_callback, sslcontext): @pytest.mark.filterwarnings(r"ignore:unclosed:ResourceWarning") @forked @pytest.mark.datafiles('tests/test_certs', keep_top_dir = True) -def test_start_runserver(datafiles, tmpworkdir, smart_caplog): +def test_start_runserver_ssl(datafiles, tmpworkdir, smart_caplog): mktree(tmpworkdir, { 'app.py': """\ from aiohttp import web From ffba94609c90b0e5c33036cc0219505ff897a109 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Tue, 28 Jan 2025 08:28:35 +0500 Subject: [PATCH 09/13] linter errs correction --- aiohttp_devtools/runserver/config.py | 16 ++++++++-------- aiohttp_devtools/runserver/main.py | 1 - aiohttp_devtools/runserver/serve.py | 3 ++- aiohttp_devtools/runserver/watch.py | 3 ++- tests/test_runserver_with_ssl.py | 11 +++++++---- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 5bfb955e..4ffcca50 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -87,13 +87,13 @@ def __init__(self, *, self.bind_address = bind_address if main_port is None: - main_port = 8000 if ssl_context_factory_name == None else 8443 + main_port = 8000 if ssl_context_factory_name is None else 8443 self.main_port = main_port self.aux_port = aux_port or (main_port + 1) self.browser_cache = browser_cache self.ssl_context_factory_name = ssl_context_factory_name logger.debug('config loaded:\n%s', self) - + @property def protocol(self) -> Literal["http", "https"]: return "http" if self.ssl_context_factory_name is None else "https" @@ -145,7 +145,7 @@ def _resolve_path(self, _path: str, check: str, arg_name: str) -> Path: if not path.is_dir(): raise AdevConfigError('{} is not a directory'.format(path)) return path - + def import_module(self) -> ModuleType: """Import and return python module. @@ -199,22 +199,22 @@ def get_app_factory(self, module: ModuleType) -> AppFactory: self.py_file.name, self.app_factory_name)) return attr # type: ignore[no-any-return] - + def get_ssl_context(self, module: ModuleType) -> Union[SSLContext, None]: if self.ssl_context_factory_name is None: return None - else: + else: try: attr = getattr(module, self.ssl_context_factory_name) except AttributeError: raise AdevConfigError("Module '{}' does not define a '{}' attribute/class".format( - self.py_file.name, self.ssl_context_factory_name)) + self.py_file.name, self.ssl_context_factory_name)) ssl_context = attr() if isinstance(ssl_context, SSLContext): return ssl_context else: - raise AdevConfigError("ssl-context-factory '{}' in module '{}' didn't return valid SSLContext".format( - self.ssl_context_factory_name, self.py_file.name)) + raise AdevConfigError("ssl-context-factory '{}' in module '{}' didn't return valid SSLContext".format( + self.ssl_context_factory_name, self.py_file.name)) async def load_app(self, app_factory: AppFactory) -> web.Application: if isinstance(app_factory, web.Application): diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py index 69fee106..746f60c2 100644 --- a/aiohttp_devtools/runserver/main.py +++ b/aiohttp_devtools/runserver/main.py @@ -23,7 +23,6 @@ class RunServer(TypedDict): ssl_context: Union[SSLContext, None] - def runserver(**config_kwargs: Any) -> RunServer: """Prepare app ready to run development server. diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index 42abe01d..ec265043 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -122,7 +122,8 @@ def shutdown() -> NoReturn: path = config.path_prefix + "/shutdown" app.router.add_route("GET", path, do_shutdown, name="_devtools.shutdown") - dft_logger.debug("Created shutdown endpoint at {}://{}:{}{}".format(config.protocol, config.host, config.main_port, path)) + dft_logger.debug("Created shutdown endpoint at {}://{}:{}{}".format( + config.protocol, config.host, config.main_port, path)) if config.static_path is not None: static_url = '{}://{}:{}/{}'.format(config.protocol, config.host, config.aux_port, static_path) diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py index 84125721..eff77b89 100644 --- a/aiohttp_devtools/runserver/watch.py +++ b/aiohttp_devtools/runserver/watch.py @@ -123,7 +123,8 @@ async def _src_reload_when_live(self, checks: int) -> None: def _start_dev_server(self) -> None: act = 'Start' if self._reloads == 0 else 'Restart' - logger.info('%sing dev server at %s://%s:%s ●', act, self._config.protocol, self._config.host, self._config.main_port) + logger.info('%sing dev server at %s://%s:%s ●', + act, self._config.protocol, self._config.host, self._config.main_port) try: tty_path = os.ttyname(sys.stdin.fileno()) diff --git a/tests/test_runserver_with_ssl.py b/tests/test_runserver_with_ssl.py index 78dacd63..6baa0df0 100644 --- a/tests/test_runserver_with_ssl.py +++ b/tests/test_runserver_with_ssl.py @@ -21,6 +21,7 @@ async def test_load_invalid_app(tmpworkdir): with pytest.raises(AiohttpDevConfigError): Config(app_path='invalid') + async def check_server_running(check_callback, sslcontext): port_open = False async with aiohttp.ClientSession(timeout=ClientTimeout(total=1)) as session: @@ -37,9 +38,10 @@ async def check_server_running(check_callback, sslcontext): await check_callback(session, sslcontext) await asyncio.sleep(.25) # TODO(aiohttp 4): Remove this hack + @pytest.mark.filterwarnings(r"ignore:unclosed:ResourceWarning") @forked -@pytest.mark.datafiles('tests/test_certs', keep_top_dir = True) +@pytest.mark.datafiles('tests/test_certs', keep_top_dir=True) def test_start_runserver_ssl(datafiles, tmpworkdir, smart_caplog): mktree(tmpworkdir, { 'app.py': """\ @@ -66,7 +68,8 @@ def get_ssl_context(): }) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - args = runserver(app_path="app.py", static_path="static_dir", bind_address="0.0.0.0", ssl_context_factory_name='get_ssl_context') + args = runserver(app_path="app.py", static_path="static_dir", + bind_address="0.0.0.0", ssl_context_factory_name='get_ssl_context') aux_app = args["app"] aux_port = args["port"] runapp_host = args["host"] @@ -75,7 +78,9 @@ def get_ssl_context(): assert runapp_host == "0.0.0.0" for startup in aux_app.on_startup: loop.run_until_complete(startup(aux_app)) + sslcontext = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + async def check_callback(session, sslcontext): print(session, sslcontext) async with session.get('https://localhost:8443/', ssl=sslcontext) as r: @@ -102,5 +107,3 @@ async def check_callback(session, sslcontext): 'adev.server.dft INFO: Starting dev server at https://localhost:8443 ●\n' ) in smart_caplog loop.run_until_complete(asyncio.sleep(.25)) # TODO(aiohttp 4): Remove this hack - - From c6191488d0f85b6498165f0d5f34fb77532b27fe Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Tue, 28 Jan 2025 09:01:32 +0500 Subject: [PATCH 10/13] ssl-context-factory config test --- tests/test_runserver_config.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py index 3e837e64..370a8eb2 100644 --- a/tests/test_runserver_config.py +++ b/tests/test_runserver_config.py @@ -90,3 +90,37 @@ def app_factory(foo): with pytest.raises(AiohttpDevConfigError, match=r"'app\.py\.app_factory' should not have required arguments"): await config.load_app(config.get_app_factory(module)) + + +@forked +async def test_no_ssl_context_factory(tmpworkdir): + mktree(tmpworkdir, { + 'app.py': """\ +def app_factory(foo): + return web.Application() +""" + }) + config = Config(app_path='app.py', ssl_context_factory_name='get_ssl_context') + module = config.import_module() + with pytest.raises(AiohttpDevConfigError, + match=r"Module 'app.py' does not define a 'get_ssl_context' attribute/class"): + await config.get_ssl_context(module) + + +@forked +async def test_invalid_ssl_context(tmpworkdir): + mktree(tmpworkdir, { + 'app.py': """\ +def app_factory(foo): + return web.Application() + +def get_ssl_context(): + return 'invalid ssl_context' +""" + }) + config = Config(app_path='app.py', ssl_context_factory_name='get_ssl_context') + module = config.import_module() + with pytest.raises(AiohttpDevConfigError, + match=r"ssl-context-factory 'get_ssl_context' in \ + module 'app.py' didn't return valid SSLContext"): + await config.get_ssl_context(module) From 8fb1e390e23a2ffb6b510ce929f4722a1c6dd4e5 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 28 Jan 2025 14:51:48 +0000 Subject: [PATCH 11/13] Update requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b35e97cb..326e2697 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,4 @@ pytest-sugar==1.0.0 pytest-timeout==2.2.0 pytest-toolbox==0.4 pytest-datafiles==3.0.0 -watchfiles==1.0.4# \ No newline at end of file +watchfiles==1.0.4 From 60ed28b28190b0a7e4d844374742045c54b63bb7 Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Tue, 28 Jan 2025 22:05:40 +0500 Subject: [PATCH 12/13] ssl config test update --- tests/test_runserver_config.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py index 370a8eb2..92d7e1aa 100644 --- a/tests/test_runserver_config.py +++ b/tests/test_runserver_config.py @@ -103,8 +103,8 @@ def app_factory(foo): config = Config(app_path='app.py', ssl_context_factory_name='get_ssl_context') module = config.import_module() with pytest.raises(AiohttpDevConfigError, - match=r"Module 'app.py' does not define a 'get_ssl_context' attribute/class"): - await config.get_ssl_context(module) + match="Module 'app.py' does not define a 'get_ssl_context' attribute/class"): + config.get_ssl_context(module) @forked @@ -121,6 +121,5 @@ def get_ssl_context(): config = Config(app_path='app.py', ssl_context_factory_name='get_ssl_context') module = config.import_module() with pytest.raises(AiohttpDevConfigError, - match=r"ssl-context-factory 'get_ssl_context' in \ - module 'app.py' didn't return valid SSLContext"): - await config.get_ssl_context(module) + match="ssl-context-factory 'get_ssl_context' in module 'app.py' didn't return valid SSLContext"): + config.get_ssl_context(module) From e34c7ad5a75486b9ccbe9ace43b00a59371b2c9a Mon Sep 17 00:00:00 2001 From: RomanZhukov Date: Wed, 29 Jan 2025 11:53:51 +0500 Subject: [PATCH 13/13] aux server back to http --- aiohttp_devtools/runserver/config.py | 8 ++++++-- aiohttp_devtools/runserver/main.py | 7 +++---- aiohttp_devtools/runserver/serve.py | 8 ++++---- aiohttp_devtools/runserver/watch.py | 9 ++++++--- tests/test_runserver_with_ssl.py | 8 ++++---- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 4ffcca50..4c58021a 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -28,6 +28,8 @@ 'create_app', ] +DEFAULT_PORT = 8000 + INFER_HOST = '' @@ -87,9 +89,11 @@ def __init__(self, *, self.bind_address = bind_address if main_port is None: - main_port = 8000 if ssl_context_factory_name is None else 8443 + main_port = DEFAULT_PORT if ssl_context_factory_name is None else DEFAULT_PORT + 443 self.main_port = main_port - self.aux_port = aux_port or (main_port + 1) + if aux_port is None: + aux_port = main_port + 1 if ssl_context_factory_name is None else DEFAULT_PORT + 1 + self.aux_port = aux_port self.browser_cache = browser_cache self.ssl_context_factory_name = ssl_context_factory_name logger.debug('config loaded:\n%s', self) diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py index 746f60c2..86d2611b 100644 --- a/aiohttp_devtools/runserver/main.py +++ b/aiohttp_devtools/runserver/main.py @@ -32,8 +32,7 @@ def runserver(**config_kwargs: Any) -> RunServer: # force a full reload in sub processes so they load an updated version of code, this must be called only once set_start_method('spawn') config = Config(**config_kwargs) - module = config.import_module() - ssl_context = config.get_ssl_context(module) + config.import_module() asyncio.run(check_port_open(config.main_port, host=config.bind_address)) @@ -51,7 +50,7 @@ def runserver(**config_kwargs: Any) -> RunServer: logger.debug('starting livereload to watch %s', config.static_path_str) aux_app.cleanup_ctx.append(static_manager.cleanup_ctx) - url = '{0.protocol}://{0.host}:{0.aux_port}'.format(config) + url = 'http://{0.host}:{0.aux_port}'.format(config) logger.info('Starting aux server at %s ◆', url) if config.static_path: @@ -59,7 +58,7 @@ def runserver(**config_kwargs: Any) -> RunServer: logger.info('serving static files from ./%s/ at %s%s', rel_path, url, config.static_url) return {"app": aux_app, "host": config.bind_address, "port": config.aux_port, - "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger, "ssl_context": ssl_context} + "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger, "ssl_context": None} def serve_static(*, static_path: str, livereload: bool = True, bind_address: str = "localhost", port: int = 8000, diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index ec265043..4e58715b 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -32,7 +32,7 @@ except ImportError: static_root_key = None # type: ignore[assignment] -LIVE_RELOAD_HOST_SNIPPET = '\n\n' +LIVE_RELOAD_HOST_SNIPPET = '\n\n' LIVE_RELOAD_LOCAL_SNIPPET = b'\n\n' LAST_RELOAD = web.AppKey("LAST_RELOAD", List[float]) @@ -84,7 +84,7 @@ async def on_prepare(request: web.Request, response: web.StreamResponse) -> None or request.path.startswith("/_debugtoolbar") or "text/html" not in response.content_type): return - lr_snippet = LIVE_RELOAD_HOST_SNIPPET.format(config.protocol, get_host(request), config.aux_port) + lr_snippet = LIVE_RELOAD_HOST_SNIPPET.format(get_host(request), config.aux_port) dft_logger.debug("appending live reload snippet '%s' to body", lr_snippet) response.body += lr_snippet.encode() response.headers[CONTENT_LENGTH] = str(len(response.body)) @@ -105,7 +105,7 @@ async def no_cache_middleware(request: web.Request, handler: Handler) -> web.Str # we set the app key even in middleware to make the switch to production easier and for backwards compat. @web.middleware async def static_middleware(request: web.Request, handler: Handler) -> web.StreamResponse: - static_url = '{}://{}:{}/{}'.format(config.protocol, get_host(request), config.aux_port, static_path) + static_url = 'http://{}:{}/{}'.format(get_host(request), config.aux_port, static_path) dft_logger.debug('setting app static_root_url to "%s"', static_url) _change_static_url(request.app, static_url) return await handler(request) @@ -126,7 +126,7 @@ def shutdown() -> NoReturn: config.protocol, config.host, config.main_port, path)) if config.static_path is not None: - static_url = '{}://{}:{}/{}'.format(config.protocol, config.host, config.aux_port, static_path) + static_url = 'http://{}:{}/{}'.format(config.host, config.aux_port, static_path) dft_logger.debug('settings app static_root_url to "%s"', static_url) _set_static_url(app, static_url) diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py index eff77b89..5adbc937 100644 --- a/aiohttp_devtools/runserver/watch.py +++ b/aiohttp_devtools/runserver/watch.py @@ -16,6 +16,7 @@ from ..logs import rs_dft_logger as logger from .config import Config from .serve import LAST_RELOAD, STATIC_PATH, WS, serve_main_app, src_reload +import ssl class WatchTask: @@ -55,7 +56,9 @@ def __init__(self, config: Config): self._reloads = 0 self._session: Optional[ClientSession] = None self._runner = None + self.ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) if config.protocol == 'https' else None assert self._config.watch_path + super().__init__(self._config.watch_path) async def _run(self, live_checks: int = 150) -> None: @@ -112,7 +115,7 @@ async def _src_reload_when_live(self, checks: int) -> None: for i in range(checks): await asyncio.sleep(0.1) try: - async with self._session.get(url): + async with self._session.get(url, ssl=self.ssl_context): pass except OSError as e: logger.debug('try %d | OSError %d app not running', i, e.errno) @@ -142,12 +145,12 @@ async def _stop_dev_server(self) -> None: if self._process.is_alive(): logger.debug('stopping server process...') if self._config.shutdown_by_url: # Workaround for signals not working on Windows - url = "http://{0.host}:{0.main_port}{0.path_prefix}/shutdown".format(self._config) + url = "{0.protocol}://{0.host}:{0.main_port}{0.path_prefix}/shutdown".format(self._config) logger.debug("Attempting to stop process via shutdown endpoint {}".format(url)) try: with suppress(ClientConnectionError): async with ClientSession() as session: - async with session.get(url): + async with session.get(url, ssl=self.ssl_context): pass except (ConnectionError, ClientError, asyncio.TimeoutError) as ex: if self._process.is_alive(): diff --git a/tests/test_runserver_with_ssl.py b/tests/test_runserver_with_ssl.py index 6baa0df0..e5c0360f 100644 --- a/tests/test_runserver_with_ssl.py +++ b/tests/test_runserver_with_ssl.py @@ -74,7 +74,7 @@ def get_ssl_context(): aux_port = args["port"] runapp_host = args["host"] assert isinstance(aux_app, aiohttp.web.Application) - assert aux_port == 8444 + assert aux_port == 8001 assert runapp_host == "0.0.0.0" for startup in aux_app.on_startup: loop.run_until_complete(startup(aux_app)) @@ -89,7 +89,7 @@ async def check_callback(session, sslcontext): text = await r.text() print(text) assert '

hello world

' in text - assert '' in text + assert '' in text async with session.get('https://localhost:8443/error', ssl=sslcontext) as r: assert r.status == 500 @@ -102,8 +102,8 @@ async def check_callback(session, sslcontext): loop.run_until_complete(shutdown(aux_app)) loop.run_until_complete(aux_app.cleanup()) assert ( - 'adev.server.dft INFO: Starting aux server at https://localhost:8444 ◆\n' - 'adev.server.dft INFO: serving static files from ./static_dir/ at https://localhost:8444/static/\n' + 'adev.server.dft INFO: Starting aux server at http://localhost:8001 ◆\n' + 'adev.server.dft INFO: serving static files from ./static_dir/ at http://localhost:8001/static/\n' 'adev.server.dft INFO: Starting dev server at https://localhost:8443 ●\n' ) in smart_caplog loop.run_until_complete(asyncio.sleep(.25)) # TODO(aiohttp 4): Remove this hack