src/genro_asgi/__main__.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 """The ``genro-asgi`` command: boot a server, and manage the named ones.16 17 Usage::18 19 genro-asgi serve ./config.py # a config.py recipe20 genro-asgi serve application=./hello.py:Hello # one app, no config21 genro-asgi serve application=pkg.mod:App --name demo # serve AND register22 genro-asgi serve demo # relaunch a registered name23 genro-asgi apps # list the registered apps24 genro-asgi stop demo # stop a running app25 genro-asgi remove demo # drop a registration26 27 The ``serve`` source resolves in this order: an ``application=<target>``28 assignment (quickstart — the target class is instantiated with no arguments and29 handed to ``AsgiServer(applications=[...])``), an existing ``.py`` path (handed30 to ``AsgiServer(config=<absolute path>)``, whose contract is the contrib31 handler's own: exactly one ``ConfigBuilder`` subclass defined in the file — this32 command ships no loader and lets that error surface), otherwise a NAME looked up33 in the registry.34 35 Explicit ``--host``/``--port`` are forwarded as ``AsgiServer`` kwargs: the36 server's own "explicit kwarg wins over the configured value" rule does the37 precedence, this command computes nothing.38 39 The registry under ``~/.genroasgi`` stores a pointer per name (``apps/<name>.json``:40 source and saved options), never a copy of the app, so relaunching by name always41 runs the current code. A served name records its pid in ``run/<name>.pid`` so42 ``apps`` shows what is running and ``stop`` can end it from another shell. A pid43 whose process is gone is stale and reads as not running.44 45 ``--reload`` runs under uvicorn's reload supervisor, which accepts only an import46 string — never a built instance. The source therefore crosses the process47 boundary as one JSON object in ``GENRO_ASGI_LAUNCHER``, and ``factory()`` rebuilds48 the very same server on every restart. Those two derogations (a module-level49 function, state in the environment) are confined to this module.50 51 Exit codes: 0 success, 2 usage errors (argparse), 1 runtime errors — reported as52 one line on stderr.53 """54 55 from __future__ import annotations56 57 import argparse58 import importlib59 import importlib.util60 import json61 import os62 import signal63 import sys64 from pathlib import Path65 from types import ModuleType66 67 68 from .asgi_server import AsgiServer69 from .lifespan import QUITTING70 from .reloading import LAUNCHER_ENV, serve_reloading71 from .config.default_config import DefaultConfig72 73 __all__ = [74 "LAUNCHER_ENV",75 "AppsRegistry",76 "CliError",77 "Cli",78 "ServerLauncher",79 "TargetResolver",80 "factory",81 "main",82 ]83 84 class CliError(Exception):85 """A runtime error the command reports as one stderr line and exit code 1."""86 87 88 class AppsRegistry:89 """The ``~/.genroasgi`` store: registered servers and the pids of the running ones.90 91 The directory is also where a deployment keeps its defaults layer, so92 ``base_dir`` comes from ``DefaultConfig`` — one default for both, one93 ``GENRO_ASGI_HOME`` relocating both, and one parameter a test can point at a94 temporary directory.95 """96 97 def __init__(self, base_dir: Path | None = None) -> None:98 self.default_config = DefaultConfig(base_dir)99 self.base_dir = self.default_config.base_dir100 self.apps_dir = self.base_dir / "apps"101 self.run_dir = self.base_dir / "run"102 103 def entry_path(self, name: str) -> Path:104 return self.apps_dir / f"{name}.json"105 106 def pid_path(self, name: str) -> Path:107 return self.run_dir / f"{name}.pid"108 109 def save(self, name: str, entry: dict) -> None:110 """Register (or update) *name* with its serve options."""111 self.apps_dir.mkdir(parents=True, exist_ok=True)112 self.entry_path(name).write_text(json.dumps(entry, indent=2), encoding="utf-8")113 114 def load(self, name: str) -> dict | None:115 """The registration stored for *name*, ``None`` when there is none."""116 path = self.entry_path(name)117 if not path.is_file():118 return None119 return json.loads(path.read_text(encoding="utf-8"))120 121 def names(self) -> list[str]:122 """The registered names, sorted."""123 if not self.apps_dir.is_dir():124 return []125 return sorted(path.stem for path in self.apps_dir.glob("*.json"))126 127 def remove(self, name: str) -> bool:128 """Drop *name*'s registration and any leftover pidfile. ``False`` if absent."""129 path = self.entry_path(name)130 if not path.is_file():131 return False132 path.unlink()133 self.clear_pid(name)134 return True135 136 def write_pid(self, name: str, pid: int) -> None:137 self.run_dir.mkdir(parents=True, exist_ok=True)138 self.pid_path(name).write_text(str(pid), encoding="utf-8")139 140 def clear_pid(self, name: str) -> None:141 self.pid_path(name).unlink(missing_ok=True)142 143 def read_pid(self, name: str) -> int | None:144 """The recorded pid IF its process is alive — the file is never trusted.145 146 A pidfile that is missing, unreadable or names a dead process all read147 the same way: not running.148 """149 path = self.pid_path(name)150 if not path.is_file():151 return None152 try:153 pid = int(path.read_text(encoding="utf-8").strip())154 except ValueError:155 return None156 try:157 os.kill(pid, 0)158 except (ProcessLookupError, PermissionError):159 return None160 return pid161 162 163 class TargetResolver:164 """Resolves an application target to its class.165 166 Two spellings: ``package.module:ClassName`` (a plain import) and167 ``path/to/file.py:ClassName`` (a single file, no packaging needed).168 """169 170 def __init__(self, target: str) -> None:171 self.target = target172 173 @property174 def parts(self) -> tuple[str, str]:175 """The module part and the class name; a target without ``:`` is an error."""176 module_part, separator, class_name = self.target.partition(":")177 if not (separator and module_part and class_name):178 raise CliError(179 f"application target must be 'package.module:ClassName' or "180 f"'path/to/file.py:ClassName', got {self.target!r}"181 )182 return module_part, class_name183 184 def resolve(self) -> type:185 """The target class itself."""186 module_part, class_name = self.parts187 if module_part.endswith(".py"):188 module = self.load_file(module_part)189 else:190 module = importlib.import_module(module_part)191 app_class = getattr(module, class_name, None)192 if app_class is None:193 raise CliError(f"{module_part} does not define {class_name!r}")194 return app_class195 196 def load_file(self, module_part: str) -> ModuleType:197 """Import a single ``.py`` file as a module of its own."""198 path = Path(module_part).resolve()199 if not path.is_file():200 raise CliError(f"application file not found: {path}")201 spec = importlib.util.spec_from_file_location(f"genro_asgi_target_{path.stem}", path)202 if spec is None or spec.loader is None:203 raise CliError(f"cannot load application module: {path}")204 module = importlib.util.module_from_spec(spec)205 # Registered BEFORE exec (importlib contract): the app class must be able206 # to find its own module through ``sys.modules[cls.__module__]``.207 sys.modules[spec.name] = module208 spec.loader.exec_module(module)209 return module210 211 212 class ServerLauncher:213 """One ``serve`` invocation: resolves its source, builds the server, boots it.214 215 A source that is neither a quickstart assignment nor an existing ``.py`` file216 is a registered name, adopted at construction: its stored source and options217 replace the ones the command line did not give.218 """219 220 def __init__(self, options: argparse.Namespace, registry: AppsRegistry) -> None:221 self.registry = registry222 self.source = options.source223 self.name = options.name224 self.host = options.host225 self.port = options.port226 self.reload = options.reload227 self.debug = options.debug228 if not (self.is_quickstart or self.is_config_path):229 self.adopt_registered(self.source)230 231 @property232 def is_quickstart(self) -> bool:233 return self.source.startswith("application=")234 235 @property236 def is_config_path(self) -> bool:237 return self.source.endswith(".py") and Path(self.source).is_file()238 239 @property240 def entry(self) -> dict:241 """What gets stored under ``--name``: the source and the given options."""242 return {243 "source": self.source,244 "host": self.host,245 "port": self.port,246 "reload": bool(self.reload),247 "debug": self.debug,248 }249 250 @property251 def server_kwargs(self) -> dict:252 """The explicitly-given host/port only — an absent key keeps the config's."""253 kwargs: dict = {}254 if self.host is not None:255 kwargs["host"] = self.host256 if self.port is not None:257 kwargs["port"] = self.port258 return kwargs259 260 @property261 def save_session_path(self) -> str | None:262 """The session snapshot file a NAMED serve arms, ``None`` for a nameless one.263 264 Giving the instance a name IS the switch: sessions of ``--name demo``265 survive a restart through ``<base_dir>/sessions/demo.pickle``.266 """267 if not self.name:268 return None269 return str(self.registry.base_dir / "sessions" / f"{self.name}.pickle")270 271 @property272 def constructor_kwargs(self) -> dict:273 """What ``AsgiServer(...)`` receives: host/port plus the armed snapshot."""274 kwargs = dict(self.server_kwargs)275 if self.save_session_path is not None:276 kwargs["save_session"] = self.save_session_path277 if self.debug is not False:278 kwargs["debug"] = self.debug279 return kwargs280 281 @property282 def quickstart_target(self) -> str:283 """The ``application=`` target, a file spelling made absolute."""284 module_part, class_name = TargetResolver(self.source.partition("=")[2]).parts285 if module_part.endswith(".py"):286 module_part = str(Path(module_part).resolve())287 return f"{module_part}:{class_name}"288 289 @property290 def launcher_payload(self) -> dict:291 """What ``factory`` needs to rebuild this server in the reloaded process.292 293 One source key (``application`` or ``config``, always absolute) plus the294 explicitly-given host/port and the armed session snapshot: an absent295 key lets the config's own value apply, exactly as it does here.296 """297 payload = dict(self.constructor_kwargs)298 if self.is_quickstart:299 payload["application"] = self.quickstart_target300 else:301 payload["config"] = str(Path(self.source).resolve())302 return payload303 304 @property305 def reload_dir(self) -> str:306 """The directory uvicorn watches: the one holding the source file.307 308 A dotted target has no file of its own to anchor on, so the working309 directory is watched instead.310 """311 module_part = (312 self.quickstart_target.partition(":")[0] if self.is_quickstart else self.source313 )314 if module_part.endswith(".py"):315 return str(Path(module_part).resolve().parent)316 return str(Path.cwd())317 318 def adopt_registered(self, name: str) -> None:319 """Replace the source and the unset options with the ones stored under *name*."""320 stored = self.registry.load(name)321 if stored is None:322 known = ", ".join(self.registry.names()) or "none registered"323 raise CliError(f"unknown app {name!r} (registered: {known})")324 self.name = self.name or name325 self.source = stored["source"]326 if self.host is None:327 self.host = stored.get("host")328 if self.port is None:329 self.port = stored.get("port")330 if not self.reload:331 self.reload = bool(stored.get("reload"))332 if self.debug is False:333 self.debug = stored.get("debug", False)334 335 def ensure_importable(self, directory: Path) -> None:336 """Put *directory* on ``sys.path`` so a config.py can import its siblings.337 338 ``python -m genro_asgi`` puts the working directory there by itself; the339 installed console script does not — without this, ``from hello import340 Hello`` inside a config.py resolves under one invocation and not the341 other.342 """343 if str(directory) not in sys.path:344 sys.path.insert(0, str(directory))345 346 def build_server(self) -> AsgiServer:347 """The server this source describes, host/port forwarded when given."""348 if self.is_quickstart:349 app_class = TargetResolver(self.source.partition("=")[2]).resolve()350 return AsgiServer(applications=[app_class()], **self.constructor_kwargs)351 if self.is_config_path:352 config_path = Path(self.source).resolve()353 self.ensure_importable(config_path.parent)354 return AsgiServer(config=str(config_path), **self.constructor_kwargs)355 raise CliError(356 f"cannot serve {self.source!r}: not an existing config.py path, "357 "not an 'application=<target>' assignment"358 )359 360 def address(self, server: AsgiServer) -> tuple[str, int]:361 """The address this boot binds: the explicit option, else what the server has.362 363 The same rule ``AsgiServer.serve`` applies — spelled out here because the364 reload supervisor binds by itself and never calls ``serve``.365 """366 host = self.host if self.host is not None else (server.config_host or "127.0.0.1")367 port = self.port if self.port is not None else (server.config_port or 0)368 return host, port369 370 def run_reloading(self, host: str, port: int) -> None:371 """Boot under the reload supervisor, with this CLI's own derivations.372 373 The derivation is the CLI's convenience and stays here: the watch root374 is the source file's directory. The launch itself is the package's375 public one (``reloading``, #39) — any other launcher reaches it with376 roots of its own choosing.377 """378 payload = self.launcher_payload379 serve_reloading(380 host=host,381 port=port,382 reload_dirs=[self.reload_dir],383 config=payload.get("config"),384 application=payload.get("application"),385 save_session=payload.get("save_session"),386 debug=payload.get("debug", False),387 )388 389 def run(self) -> int:390 """Boot the server (blocking), registering the name and its pid first."""391 server = self.build_server()392 if self.name:393 self.registry.save(self.name, self.entry)394 # The pidfile goes down BEFORE uvicorn starts: with --reload this395 # records the supervisor, which is the process ``stop`` must signal.396 self.registry.write_pid(self.name, os.getpid())397 host, port = self.address(server)398 print(f"genro-asgi serving http://{host}:{port}", flush=True)399 try:400 if self.reload:401 self.run_reloading(host, port)402 else:403 server.serve(**self.server_kwargs)404 except KeyboardInterrupt:405 print("Shutdown.")406 finally:407 if self.name:408 self.registry.clear_pid(self.name)409 return 0410 411 412 class Cli:413 """The command: one parser, one subcommand method each, one exit code."""414 415 def __init__(self, registry: AppsRegistry | None = None) -> None:416 self.registry = registry or AppsRegistry()417 418 def parser(self) -> argparse.ArgumentParser:419 parser = argparse.ArgumentParser(prog="genro-asgi", description="Run and manage ASGI servers.")420 commands = parser.add_subparsers(dest="command", required=True)421 422 serve = commands.add_parser("serve", help="run a server from a config.py, a target or a name")423 serve.add_argument("source", help="a config.py path, 'application=<target>', or a registered name")424 serve.add_argument("--host", help="bind host (overrides the configured one)")425 serve.add_argument("--port", type=int, help="bind port (overrides the configured one)")426 serve.add_argument("--reload", action="store_true", help="restart on source changes")427 serve.add_argument(428 "--debug",429 nargs="?",430 const=True,431 default=False,432 help="declare the server runs in debug mode (optional comma-separated parameters)",433 )434 serve.add_argument("--name", help="register the server under this name")435 serve.set_defaults(handler=self.serve)436 437 commands.add_parser("apps", help="list the registered servers").set_defaults(handler=self.apps)438 439 stop = commands.add_parser("stop", help="stop a running registered server")440 stop.add_argument("name")441 stop.set_defaults(handler=self.stop)442 443 remove = commands.add_parser("remove", help="drop a registration")444 remove.add_argument("name")445 remove.set_defaults(handler=self.remove)446 return parser447 448 def run(self, argv: list[str] | None = None) -> int:449 options = self.parser().parse_args(argv)450 try:451 return options.handler(options)452 except CliError as error:453 print(f"Error: {error}", file=sys.stderr)454 return 1455 456 def serve(self, options: argparse.Namespace) -> int:457 return ServerLauncher(options, self.registry).run()458 459 def apps(self, options: argparse.Namespace) -> int:460 names = self.registry.names()461 if not names:462 print("No registered servers (register one with: genro-asgi serve <source> --name <name>)")463 return 0464 for name in names:465 entry = self.registry.load(name) or {}466 pid = self.registry.read_pid(name)467 status = f"running (pid {pid})" if pid else "stopped"468 address = f"{entry.get('host') or '-'}:{entry.get('port') or '-'}"469 print(f"{name:<20} {status:<20} {address:<24} {entry.get('source') or '-'}")470 return 0471 472 def stop(self, options: argparse.Namespace) -> int:473 pid = self.registry.read_pid(options.name)474 if pid is None:475 self.registry.clear_pid(options.name)476 print(f"{options.name}: not running")477 return 0478 try:479 os.kill(pid, signal.SIGTERM)480 except ProcessLookupError:481 # The process died between the liveness probe and the signal482 # (TOCTOU): same outcome as finding it already stopped.483 self.registry.clear_pid(options.name)484 print(f"{options.name}: not running")485 return 0486 print(f"{options.name}: stopped (pid {pid})")487 return 0488 489 def remove(self, options: argparse.Namespace) -> int:490 if self.registry.read_pid(options.name) is not None:491 raise CliError(f"{options.name} is running — stop it first")492 if not self.registry.remove(options.name):493 raise CliError(f"{options.name}: not registered")494 print(f"{options.name}: removed")495 return 0496 497 498 def factory() -> AsgiServer:499 """Rebuild the server described in ``GENRO_ASGI_LAUNCHER``.500 501 The import-string target of the reload supervisor, and the only module-level502 function here: uvicorn imports it by name in each restarted process, where503 nothing of the parent survives except the environment.504 """505 payload = os.environ.get(LAUNCHER_ENV)506 if payload is None:507 raise CliError(f"{LAUNCHER_ENV} is not set — factory() runs only under 'genro-asgi serve --reload'")508 try:509 described = json.loads(payload)510 except json.JSONDecodeError as error:511 raise CliError(f"{LAUNCHER_ENV} is not valid JSON: {error}") from error512 kwargs = {513 key: described[key] for key in ("host", "port", "save_session", "debug") if key in described514 }515 if "application" in described:516 server = AsgiServer(517 applications=[TargetResolver(described["application"]).resolve()()], **kwargs518 )519 elif "config" in described:520 # The reloaded process starts fresh: the sibling-import path the parent521 # inserted (ServerLauncher.ensure_importable) must be re-inserted here.522 parent = str(Path(described["config"]).parent)523 if parent not in sys.path:524 sys.path.insert(0, parent)525 server = AsgiServer(config=described["config"], **kwargs)526 else:527 raise CliError(f"{LAUNCHER_ENV} carries neither an 'application' nor a 'config' key")528 # Under the reload supervisor every exit is a deliberate save: the child is529 # killed at each source change, and the next one adopts what this one froze530 # (dev-reload auto-soft, the orientations' §4).531 server.shutdown_mode = QUITTING532 return server533 534 535 def main(argv: list[str] | None = None) -> int:536 """The ``genro-asgi`` console entry point."""537 return Cli().run(argv)538 539 540 if __name__ == "__main__":541 sys.exit(main())