tests/core/test_cli.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """CLI tests: the registry, target resolution, and the built (never booted) server.16 17 No server is ever started here: ``ServerLauncher.build_server`` is exercised on18 its own, so a recipe error surfaces as a boot error exactly as it does in19 ``test_config.py``. The registry always gets ``tmp_path`` as its base_dir.20 """21 22 from __future__ import annotations23 24 import json25 import os26 import signal27 import sys28 from pathlib import Path29 30 import pytest31 32 from genro_asgi.__main__ import (33 LAUNCHER_ENV,34 AppsRegistry,35 Cli,36 CliError,37 ServerLauncher,38 TargetResolver,39 factory,40 )41 42 CONFIG_RECIPE = (43 "from genro_asgi.config import AsgiConfigBuilder\n"44 "\n"45 "\n"46 "class ServerConfiguration(AsgiConfigBuilder):\n"47 " def main(self, root):\n"48 " cfg = root.configuration()\n"49 " cfg.server(host='10.0.0.1', port=8321)\n"50 )51 52 APP_MODULE = (53 "from genro_asgi.application import BaseApplication\n"54 "\n"55 "\n"56 "class Hello(BaseApplication):\n"57 " pass\n"58 )59 60 61 def parse(cli: Cli, argv: list[str]):62 """The parsed options of one invocation (no handler is called)."""63 return cli.parser().parse_args(argv)64 65 66 class TestAppsRegistry:67 def test_save_load_and_names(self, tmp_path: Path) -> None:68 registry = AppsRegistry(base_dir=tmp_path)69 registry.save("beta", {"source": "./b.py", "host": None, "port": None, "reload": False})70 registry.save("alpha", {"source": "./a.py", "host": "0.0.0.0", "port": 9000, "reload": True})71 assert registry.names() == ["alpha", "beta"]72 stored = registry.load("alpha")73 assert stored is not None and stored["port"] == 900074 assert registry.load("missing") is None75 76 def test_names_with_no_store_yet(self, tmp_path: Path) -> None:77 assert AppsRegistry(base_dir=tmp_path / "nothing").names() == []78 79 def test_remove_drops_entry_and_pidfile(self, tmp_path: Path) -> None:80 registry = AppsRegistry(base_dir=tmp_path)81 registry.save("demo", {"source": "./a.py"})82 registry.write_pid("demo", os.getpid())83 assert registry.remove("demo") is True84 assert registry.names() == []85 assert not registry.pid_path("demo").is_file()86 assert registry.remove("demo") is False87 88 def test_a_live_pid_reads_back(self, tmp_path: Path) -> None:89 registry = AppsRegistry(base_dir=tmp_path)90 registry.write_pid("demo", os.getpid())91 assert registry.read_pid("demo") == os.getpid()92 93 def test_a_stale_pidfile_reads_as_not_running(self, tmp_path: Path) -> None:94 registry = AppsRegistry(base_dir=tmp_path)95 registry.run_dir.mkdir(parents=True)96 registry.pid_path("demo").write_text("999999999", encoding="utf-8")97 assert registry.read_pid("demo") is None98 99 def test_an_unreadable_pidfile_reads_as_not_running(self, tmp_path: Path) -> None:100 registry = AppsRegistry(base_dir=tmp_path)101 registry.run_dir.mkdir(parents=True)102 registry.pid_path("demo").write_text("not-a-pid", encoding="utf-8")103 assert registry.read_pid("demo") is None104 assert registry.read_pid("never-written") is None105 106 107 class TestTargetResolver:108 def test_dotted_target(self) -> None:109 resolved = TargetResolver("genro_asgi.application:BaseApplication").resolve()110 assert resolved.__name__ == "BaseApplication"111 112 def test_file_target(self, tmp_path: Path) -> None:113 module = tmp_path / "hello.py"114 module.write_text(APP_MODULE)115 resolved = TargetResolver(f"{module}:Hello").resolve()116 assert resolved.__name__ == "Hello"117 118 def test_a_target_without_colon_is_an_error(self) -> None:119 with pytest.raises(CliError, match="package.module:ClassName"):120 TargetResolver("genro_asgi.application").resolve()121 122 def test_a_missing_file_is_an_error(self, tmp_path: Path) -> None:123 with pytest.raises(CliError, match="application file not found"):124 TargetResolver(f"{tmp_path / 'absent.py'}:Hello").resolve()125 126 def test_an_undefined_class_is_an_error(self) -> None:127 with pytest.raises(CliError, match="does not define 'Nope'"):128 TargetResolver("genro_asgi.application:Nope").resolve()129 130 131 class TestServeSourceResolution:132 def test_a_config_py_path_builds_a_configured_server(self, tmp_path: Path) -> None:133 module = tmp_path / "config.py"134 module.write_text(CONFIG_RECIPE)135 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))136 launcher = ServerLauncher(parse(cli, ["serve", str(module)]), cli.registry)137 server = launcher.build_server()138 assert server.config_host == "10.0.0.1"139 assert server.config_port == 8321140 141 def test_a_config_importing_a_sibling_module_resolves(142 self, tmp_path: Path, monkeypatch143 ) -> None:144 # The console script — unlike ``python -m`` — does not put the working145 # directory on sys.path: the launcher must insert the config's own one.146 (tmp_path / "sibling_app.py").write_text(APP_MODULE)147 module = tmp_path / "config.py"148 module.write_text(149 "from genro_asgi.config import AsgiConfigBuilder\n"150 "from sibling_app import Hello\n"151 "\n"152 "\n"153 "class ServerConfiguration(AsgiConfigBuilder):\n"154 " def main(self, root):\n"155 " cfg = root.configuration()\n"156 " cfg.applications().application(code='hello', mount='', app_class=Hello)\n"157 )158 monkeypatch.setattr(sys, "path", [p for p in sys.path if p != str(tmp_path)])159 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))160 launcher = ServerLauncher(parse(cli, ["serve", str(module)]), cli.registry)161 server = launcher.build_server()162 assert "hello" in server.applications163 164 def test_explicit_host_and_port_win_over_the_recipe(self, tmp_path: Path) -> None:165 module = tmp_path / "config.py"166 module.write_text(CONFIG_RECIPE)167 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))168 options = parse(cli, ["serve", str(module), "--host", "0.0.0.0", "--port", "7000"])169 server = ServerLauncher(options, cli.registry).build_server()170 assert (server.config_host, server.config_port) == ("0.0.0.0", 7000)171 172 def test_a_quickstart_target_builds_the_named_application(self, tmp_path: Path) -> None:173 module = tmp_path / "hello.py"174 module.write_text(APP_MODULE)175 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))176 options = parse(cli, ["serve", f"application={module}:Hello"])177 server = ServerLauncher(options, cli.registry).build_server()178 assert "Hello" in {type(app).__name__ for app in server.applications.values()}179 180 def test_an_unknown_name_lists_the_registered_ones(self, tmp_path: Path) -> None:181 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))182 cli.registry.save("demo", {"source": "./a.py"})183 with pytest.raises(CliError, match="unknown app 'ghost' \\(registered: demo\\)"):184 ServerLauncher(parse(cli, ["serve", "ghost"]), cli.registry)185 186 def test_an_unknown_name_with_an_empty_registry(self, tmp_path: Path) -> None:187 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))188 with pytest.raises(CliError, match="none registered"):189 ServerLauncher(parse(cli, ["serve", "ghost"]), cli.registry)190 191 def test_a_registered_name_restores_source_and_options(self, tmp_path: Path) -> None:192 module = tmp_path / "config.py"193 module.write_text(CONFIG_RECIPE)194 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))195 cli.registry.save(196 "demo", {"source": str(module), "host": "127.0.0.5", "port": 9111, "reload": True}197 )198 launcher = ServerLauncher(parse(cli, ["serve", "demo"]), cli.registry)199 assert (launcher.source, launcher.host, launcher.port) == (str(module), "127.0.0.5", 9111)200 assert launcher.reload is True201 assert launcher.name == "demo"202 assert launcher.build_server().config_port == 9111203 204 def test_a_command_line_option_wins_over_the_stored_one(self, tmp_path: Path) -> None:205 module = tmp_path / "config.py"206 module.write_text(CONFIG_RECIPE)207 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))208 cli.registry.save("demo", {"source": str(module), "host": "127.0.0.5", "port": 9111})209 launcher = ServerLauncher(parse(cli, ["serve", "demo", "--port", "9500"]), cli.registry)210 assert (launcher.host, launcher.port) == ("127.0.0.5", 9500)211 212 def test_a_stored_source_that_no_longer_exists_is_an_error(self, tmp_path: Path) -> None:213 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))214 cli.registry.save("demo", {"source": str(tmp_path / "gone.py")})215 launcher = ServerLauncher(parse(cli, ["serve", "demo"]), cli.registry)216 with pytest.raises(CliError, match="cannot serve"):217 launcher.build_server()218 219 220 class TestArgumentParsing:221 def test_serve_options_become_the_registry_entry(self, tmp_path: Path) -> None:222 module = tmp_path / "config.py"223 module.write_text(CONFIG_RECIPE)224 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))225 options = parse(226 cli, ["serve", str(module), "--host", "0.0.0.0", "--port", "8080", "--reload", "--name", "demo"]227 )228 launcher = ServerLauncher(options, cli.registry)229 assert launcher.entry == {230 "source": str(module),231 "host": "0.0.0.0",232 "port": 8080,233 "reload": True,234 "debug": False,235 }236 assert launcher.server_kwargs == {"host": "0.0.0.0", "port": 8080}237 238 def test_no_option_given_forwards_no_kwarg(self, tmp_path: Path) -> None:239 module = tmp_path / "config.py"240 module.write_text(CONFIG_RECIPE)241 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))242 launcher = ServerLauncher(parse(cli, ["serve", str(module)]), cli.registry)243 assert launcher.server_kwargs == {}244 245 246 class TestSaveSessionWiring:247 """Naming an instance IS the switch: the snapshot file takes the name."""248 249 def test_a_named_serve_arms_the_snapshot(self, tmp_path: Path) -> None:250 module = tmp_path / "config.py"251 module.write_text(CONFIG_RECIPE)252 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))253 launcher = ServerLauncher(parse(cli, ["serve", str(module), "--name", "demo"]), cli.registry)254 expected = str(tmp_path / "sessions" / "demo.pickle")255 assert launcher.save_session_path == expected256 assert launcher.constructor_kwargs == {"save_session": expected}257 assert launcher.server_kwargs == {} # serve() never sees the snapshot kwarg258 assert launcher.build_server().save_session == Path(expected)259 260 def test_a_nameless_serve_stays_volatile(self, tmp_path: Path) -> None:261 module = tmp_path / "config.py"262 module.write_text(CONFIG_RECIPE)263 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))264 launcher = ServerLauncher(parse(cli, ["serve", str(module)]), cli.registry)265 assert launcher.save_session_path is None266 assert "save_session" not in launcher.constructor_kwargs267 assert launcher.build_server().save_session is None268 269 def test_relaunching_a_registered_name_arms_the_same_file(self, tmp_path: Path) -> None:270 module = tmp_path / "config.py"271 module.write_text(CONFIG_RECIPE)272 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))273 cli.registry.save("demo", {"source": str(module)})274 launcher = ServerLauncher(parse(cli, ["serve", "demo"]), cli.registry)275 assert launcher.save_session_path == str(tmp_path / "sessions" / "demo.pickle")276 277 def test_a_missing_subcommand_is_a_usage_error(self) -> None:278 with pytest.raises(SystemExit) as exit_info:279 Cli().run([])280 assert exit_info.value.code == 2281 282 283 class TestRegistryCommands:284 def test_apps_reports_nothing_registered(self, tmp_path: Path, capsys) -> None:285 assert Cli(registry=AppsRegistry(base_dir=tmp_path)).run(["apps"]) == 0286 assert "No registered servers" in capsys.readouterr().out287 288 def test_apps_reports_status_source_and_address(self, tmp_path: Path, capsys) -> None:289 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))290 cli.registry.save("demo", {"source": "./a.py", "host": "0.0.0.0", "port": 8080})291 cli.registry.save("idle", {"source": "./b.py", "host": None, "port": None})292 cli.registry.write_pid("demo", os.getpid())293 assert cli.run(["apps"]) == 0294 out = capsys.readouterr().out295 assert f"running (pid {os.getpid()})" in out296 assert "0.0.0.0:8080" in out297 assert "./a.py" in out298 assert "stopped" in out299 assert "-:-" in out300 301 def test_stop_signals_the_recorded_pid(self, tmp_path: Path, capsys, monkeypatch) -> None:302 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))303 cli.registry.write_pid("demo", os.getpid())304 signalled: list[tuple[int, int]] = []305 original = os.kill306 307 def spy(pid: int, sig: int) -> None:308 if sig == 0: # the liveness probe stays real309 original(pid, sig)310 else:311 signalled.append((pid, sig))312 313 monkeypatch.setattr(os, "kill", spy)314 assert cli.run(["stop", "demo"]) == 0315 assert signalled == [(os.getpid(), signal.SIGTERM)]316 assert "stopped" in capsys.readouterr().out317 318 def test_stop_a_process_dying_between_probe_and_signal(319 self, tmp_path: Path, capsys, monkeypatch320 ) -> None:321 # TOCTOU: the probe (sig 0) sees the process alive, the SIGTERM finds322 # it gone — same outcome as finding it already stopped, one line out.323 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))324 cli.registry.write_pid("demo", os.getpid())325 original = os.kill326 327 def dies_after_probe(pid: int, sig: int) -> None:328 if sig == 0:329 original(pid, sig)330 else:331 raise ProcessLookupError(pid)332 333 monkeypatch.setattr(os, "kill", dies_after_probe)334 assert cli.run(["stop", "demo"]) == 0335 assert not cli.registry.pid_path("demo").is_file()336 assert "not running" in capsys.readouterr().out337 338 def test_stop_a_dead_app_cleans_the_stale_pidfile(self, tmp_path: Path, capsys) -> None:339 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))340 cli.registry.run_dir.mkdir(parents=True)341 cli.registry.pid_path("demo").write_text("999999999", encoding="utf-8")342 assert cli.run(["stop", "demo"]) == 0343 assert not cli.registry.pid_path("demo").is_file()344 assert "not running" in capsys.readouterr().out345 346 def test_remove_refuses_a_running_app(self, tmp_path: Path, capsys) -> None:347 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))348 cli.registry.save("demo", {"source": "./a.py"})349 cli.registry.write_pid("demo", os.getpid())350 assert cli.run(["remove", "demo"]) == 1351 assert "stop it first" in capsys.readouterr().err352 assert cli.registry.names() == ["demo"]353 354 def test_remove_drops_a_stopped_app(self, tmp_path: Path, capsys) -> None:355 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))356 cli.registry.save("demo", {"source": "./a.py"})357 assert cli.run(["remove", "demo"]) == 0358 assert cli.registry.names() == []359 assert "removed" in capsys.readouterr().out360 361 def test_remove_an_unregistered_name_is_an_error(self, tmp_path: Path, capsys) -> None:362 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))363 assert cli.run(["remove", "ghost"]) == 1364 assert "not registered" in capsys.readouterr().err365 366 367 class TestReloadPayload:368 """What crosses the process boundary — the supervisor itself is not started."""369 370 def test_a_config_source_travels_as_an_absolute_path(self, tmp_path: Path, monkeypatch) -> None:371 module = tmp_path / "config.py"372 module.write_text(CONFIG_RECIPE)373 monkeypatch.chdir(tmp_path)374 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))375 launcher = ServerLauncher(parse(cli, ["serve", "config.py", "--reload"]), cli.registry)376 assert launcher.launcher_payload == {"config": str(module.resolve())}377 assert launcher.reload_dir == str(tmp_path.resolve())378 379 def test_a_file_target_travels_absolute_too(self, tmp_path: Path, monkeypatch) -> None:380 module = tmp_path / "hello.py"381 module.write_text(APP_MODULE)382 monkeypatch.chdir(tmp_path)383 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))384 launcher = ServerLauncher(parse(cli, ["serve", "application=hello.py:Hello"]), cli.registry)385 assert launcher.launcher_payload == {"application": f"{module.resolve()}:Hello"}386 assert launcher.reload_dir == str(tmp_path.resolve())387 388 def test_a_dotted_target_watches_the_working_directory(self, tmp_path: Path, monkeypatch) -> None:389 monkeypatch.chdir(tmp_path)390 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))391 options = parse(cli, ["serve", "application=genro_asgi.application:BaseApplication"])392 launcher = ServerLauncher(options, cli.registry)393 payload = launcher.launcher_payload394 assert payload == {"application": "genro_asgi.application:BaseApplication"}395 assert launcher.reload_dir == str(tmp_path.resolve())396 397 def test_only_the_explicit_host_and_port_are_carried(self, tmp_path: Path) -> None:398 module = tmp_path / "config.py"399 module.write_text(CONFIG_RECIPE)400 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))401 options = parse(cli, ["serve", str(module), "--reload", "--port", "7100"])402 payload = ServerLauncher(options, cli.registry).launcher_payload403 assert payload == {"config": str(module.resolve()), "port": 7100}404 405 def test_the_reload_boot_exports_the_payload_and_binds_the_configured_address(406 self, tmp_path: Path, monkeypatch, capsys407 ) -> None:408 module = tmp_path / "config.py"409 module.write_text(CONFIG_RECIPE)410 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))411 launched: dict = {}412 monkeypatch.setattr(413 "genro_asgi.reloading.uvicorn.run",414 lambda target, **kwargs: launched.update(target=target, **kwargs),415 )416 # monkeypatch owns the variable, so the value the launcher writes is417 # restored after the test instead of leaking into the environment.418 monkeypatch.setenv(LAUNCHER_ENV, "")419 assert cli.run(["serve", str(module), "--reload", "--name", "demo"]) == 0420 assert json.loads(os.environ[LAUNCHER_ENV]) == {421 "config": str(module.resolve()),422 "save_session": str(tmp_path / "sessions" / "demo.pickle"),423 "host": "10.0.0.1",424 "port": 8321,425 }426 assert launched == {427 "target": "genro_asgi.__main__:factory",428 "factory": True,429 "reload": True,430 "reload_dirs": [str(tmp_path.resolve())],431 "reload_excludes": None,432 "host": "10.0.0.1",433 "port": 8321,434 }435 assert not cli.registry.pid_path("demo").is_file()436 assert "10.0.0.1:8321" in capsys.readouterr().out437 438 439 class TestFactory:440 """The rebuild on the far side of the boundary."""441 442 def test_a_config_payload_rebuilds_the_configured_server(self, tmp_path: Path, monkeypatch) -> None:443 module = tmp_path / "config.py"444 module.write_text(CONFIG_RECIPE)445 monkeypatch.setenv(LAUNCHER_ENV, json.dumps({"config": str(module)}))446 server = factory()447 assert (server.config_host, server.config_port) == ("10.0.0.1", 8321)448 449 def test_an_application_payload_rebuilds_the_named_application(self, tmp_path: Path, monkeypatch) -> None:450 module = tmp_path / "hello.py"451 module.write_text(APP_MODULE)452 monkeypatch.setenv(LAUNCHER_ENV, json.dumps({"application": f"{module}:Hello"}))453 server = factory()454 assert "Hello" in {type(app).__name__ for app in server.applications.values()}455 456 def test_explicit_host_and_port_in_the_payload_win_over_the_recipe(457 self, tmp_path: Path, monkeypatch458 ) -> None:459 module = tmp_path / "config.py"460 module.write_text(CONFIG_RECIPE)461 payload = {"config": str(module), "host": "0.0.0.0", "port": 7100}462 monkeypatch.setenv(LAUNCHER_ENV, json.dumps(payload))463 server = factory()464 assert (server.config_host, server.config_port) == ("0.0.0.0", 7100)465 466 def test_a_config_payload_with_a_sibling_import_resolves(467 self, tmp_path: Path, monkeypatch468 ) -> None:469 # The reloaded process starts fresh: factory() re-inserts the config's470 # directory exactly as the parent's launcher did.471 (tmp_path / "sibling_reload.py").write_text(APP_MODULE.replace("Hello", "Hot"))472 module = tmp_path / "config.py"473 module.write_text(474 "from genro_asgi.config import AsgiConfigBuilder\n"475 "from sibling_reload import Hot\n"476 "\n"477 "\n"478 "class ServerConfiguration(AsgiConfigBuilder):\n"479 " def main(self, root):\n"480 " cfg = root.configuration()\n"481 " cfg.applications().application(code='hot', mount='', app_class=Hot)\n"482 )483 monkeypatch.setattr(sys, "path", [p for p in sys.path if p != str(tmp_path)])484 monkeypatch.setenv(LAUNCHER_ENV, json.dumps({"config": str(module)}))485 server = factory()486 assert "hot" in server.applications487 488 def test_a_missing_variable_names_it(self, monkeypatch) -> None:489 monkeypatch.delenv(LAUNCHER_ENV, raising=False)490 with pytest.raises(CliError, match=f"{LAUNCHER_ENV} is not set"):491 factory()492 493 def test_malformed_json_is_an_error(self, monkeypatch) -> None:494 monkeypatch.setenv(LAUNCHER_ENV, "{not json")495 with pytest.raises(CliError, match="is not valid JSON"):496 factory()497 498 def test_a_payload_without_a_source_key_is_an_error(self, monkeypatch) -> None:499 monkeypatch.setenv(LAUNCHER_ENV, json.dumps({"host": "0.0.0.0"}))500 with pytest.raises(CliError, match="neither an 'application' nor a 'config' key"):501 factory()502 503 def test_save_session_in_the_payload_arms_the_snapshot(504 self, tmp_path: Path, monkeypatch505 ) -> None:506 module = tmp_path / "config.py"507 module.write_text(CONFIG_RECIPE)508 snapshot = str(tmp_path / "sessions" / "demo.pickle")509 payload = {"config": str(module), "save_session": snapshot}510 monkeypatch.setenv(LAUNCHER_ENV, json.dumps(payload))511 assert factory().save_session == Path(snapshot)512 513 514 class TestDebugAndReloadTrigger:515 def test_debug_travels_from_the_flag_to_the_server(self, tmp_path: Path) -> None:516 module = tmp_path / "config.py"517 module.write_text(CONFIG_RECIPE)518 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))519 options = parse(cli, ["serve", str(module), "--debug", "sql,timing"])520 server = ServerLauncher(options, cli.registry).build_server()521 assert server.debug == "sql,timing"522 523 def test_a_bare_debug_flag_reads_true_and_its_absence_false(self, tmp_path: Path) -> None:524 module = tmp_path / "config.py"525 module.write_text(CONFIG_RECIPE)526 cli = Cli(registry=AppsRegistry(base_dir=tmp_path))527 flagged = parse(cli, ["serve", str(module), "--debug"])528 plain = parse(cli, ["serve", str(module)])529 assert ServerLauncher(flagged, cli.registry).build_server().debug is True530 assert ServerLauncher(plain, cli.registry).build_server().debug is False531 532 def test_the_reloaded_child_declares_its_exits_save(self, tmp_path: Path, monkeypatch) -> None:533 from genro_asgi.lifespan import QUITTING, STOPPING534 535 module = tmp_path / "config.py"536 module.write_text(CONFIG_RECIPE)537 monkeypatch.setenv(LAUNCHER_ENV, json.dumps({"config": str(module), "debug": True}))538 server = factory()539 assert server.shutdown_mode == QUITTING540 assert server.debug is True541 # and outside the supervisor the default stays the dry exit542 from genro_asgi import BaseServer543 from genro_asgi.application import BaseApplication544 assert BaseServer(applications=[BaseApplication(mount="")]).shutdown_mode == STOPPING