Skip to content

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())