From cc964c3ae81e704f3d2806df7bd6d328f45f7124 Mon Sep 17 00:00:00 2001 From: zoltanf Date: Wed, 20 May 2026 22:12:20 +0200 Subject: [PATCH] perf(app): lazy-load subcommand modules to halve binary startup LazyTyperGroup keeps `rc0.commands.*` out of the import graph until a user actually runs a subcommand. Top-level help and version no longer pay for httpx, pydantic models, or rich import overhead. Measured on Apple Silicon (warm cold-start, median of 7 runs): - `rc0 --version`: ~270ms -> ~115ms - `rc0 --help`: ~310ms -> ~155ms `tests/perf/test_startup.py` (gated by `RC0_PERF=1` plus a built binary) asserts both stay under 200ms so future regressions fail CI. `packaging/pyinstaller-hooks/hook-rc0.py` runs `collect_submodules` over `rc0.commands` so PyInstaller (which can't see `importlib.import_module` strings) still bundles every subcommand. The CI release workflow and the local `rc0.spec` both point at this hooks dir. `introspect` walks via `list_commands`/`get_command` so the JSON schema still enumerates lazily-registered subcommands. --- .github/workflows/release.yml | 5 + CHANGELOG.md | 11 +++ packaging/pyinstaller-hooks/hook-rc0.py | 11 +++ src/rc0/app.py | 125 +++++++++++++++++++----- src/rc0/commands/introspect.py | 7 +- tests/perf/__init__.py | 0 tests/perf/test_startup.py | 72 ++++++++++++++ tests/unit/test_usage_hint.py | 3 +- uv.lock | 2 +- 9 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 packaging/pyinstaller-hooks/hook-rc0.py create mode 100644 tests/perf/__init__.py create mode 100644 tests/perf/test_startup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa63677..fe6a21b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,15 +55,20 @@ jobs: shell: bash run: | # PyInstaller's --add-data separator: ':' on Unix/macOS, ';' on Windows. + # The --additional-hooks-dir flag wires in packaging/pyinstaller-hooks/hook-rc0.py, + # which collects rc0.commands.* submodules that LazyTyperGroup imports + # on demand and would otherwise be missing from the bundle. if [ "${{ runner.os }}" = "Windows" ]; then uv run pyinstaller --onefile --name rc0 --noupx \ --add-data "src/rc0/topics;rc0/topics" \ --add-data "src/rc0/skill;rc0/skill" \ + --additional-hooks-dir packaging/pyinstaller-hooks \ src/rc0/__main__.py else uv run pyinstaller --onedir --name rc0 --noupx \ --add-data "src/rc0/topics:rc0/topics" \ --add-data "src/rc0/skill:rc0/skill" \ + --additional-hooks-dir packaging/pyinstaller-hooks \ src/rc0/__main__.py fi - name: Strip symbols (Linux only) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f4206..5f56606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Subcommand modules (`rc0.commands.acme`, `…auth`, `…zone`, etc.) are + now imported on demand instead of at startup. The packaged binary's + warm cold-start dropped from ~270ms to ~115ms for `rc0 --version` and + from ~310ms to ~155ms for `rc0 --help` on Apple Silicon. A new + `tests/perf/test_startup.py` (gated by `RC0_PERF=1` and a built + binary) enforces a 200ms budget so regressions fail CI. The + refactor introduces `LazyTyperGroup` and `_LazyStub` in `rc0.app`; + subcommand modules referenced through these stubs are listed under + `hiddenimports` in `rc0.spec` so PyInstaller still bundles them. + ## [2.1.2] — 2026-05-20 ### Fixed diff --git a/packaging/pyinstaller-hooks/hook-rc0.py b/packaging/pyinstaller-hooks/hook-rc0.py new file mode 100644 index 0000000..0ab7922 --- /dev/null +++ b/packaging/pyinstaller-hooks/hook-rc0.py @@ -0,0 +1,11 @@ +"""PyInstaller hook for the rc0 package. + +Subcommand modules under ``rc0.commands`` are imported lazily by +``rc0.app.LazyTyperGroup`` to keep cold startup under 200ms. Static analysis +cannot follow ``importlib.import_module`` strings, so we tell PyInstaller to +bundle every submodule of ``rc0.commands`` explicitly. +""" + +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = collect_submodules("rc0.commands") diff --git a/src/rc0/app.py b/src/rc0/app.py index 2b63a60..c4becd3 100644 --- a/src/rc0/app.py +++ b/src/rc0/app.py @@ -5,6 +5,7 @@ from __future__ import annotations +import importlib import logging import os import platform @@ -14,49 +15,119 @@ import click import typer +from typer.core import TyperGroup import rc0 from rc0.app_state import AppState from rc0.client.errors import ConfirmationDeclined, Rc0Error -from rc0.commands import acme as acme_cmd -from rc0.commands import auth as auth_cmd -from rc0.commands import config as config_cmd -from rc0.commands import dnssec as dnssec_cmd -from rc0.commands import help as help_cmd from rc0.commands import introspect as introspect_cmd -from rc0.commands import messages as messages_cmd -from rc0.commands import record as record_cmd -from rc0.commands import report as report_cmd -from rc0.commands import settings as settings_cmd -from rc0.commands import skill as skill_cmd -from rc0.commands import stats as stats_cmd -from rc0.commands import tsig as tsig_cmd -from rc0.commands import zone as zone_cmd from rc0.config import load_profile from rc0.output import OutputFormat, render +# Subcommands are resolved on demand to keep cold startup under 200ms in the +# packaged binary. Each entry maps the user-visible name to the module that +# defines the Typer subapp plus the short help shown on ``rc0 --help``. The +# help text mirrors the value previously passed to ``add_typer(help=...)`` so +# ``rc0 --help`` can be rendered without importing any of these modules. +_LAZY_SUBCOMMANDS: dict[str, tuple[str, str]] = { + "acme": ("rc0.commands.acme", "Manage ACME DNS-01 challenge records."), + "auth": ("rc0.commands.auth", "Authenticate with the RcodeZero API."), + "config": ("rc0.commands.config", "Read and write rc0 configuration."), + "dnssec": ("rc0.commands.dnssec", "Manage DNSSEC for zones."), + "help": ("rc0.commands.help", "Long-form topic documentation."), + "messages": ("rc0.commands.messages", "Inspect queued account messages."), + "record": ("rc0.commands.record", "Manage RRsets."), + "report": ("rc0.commands.report", "Account-level reports."), + "settings": ("rc0.commands.settings", "Manage account-level settings."), + "skill": ("rc0.commands.skill", "Manage the rc0 Claude Code skill."), + "stats": ("rc0.commands.stats", "Account statistics."), + "tsig": ("rc0.commands.tsig", "Manage TSIG keys."), + "zone": ("rc0.commands.zone", "Manage RcodeZero zones."), +} + + +class _LazyStub(click.Group): + """Placeholder group that defers importing its module until needed. + + Rendering ``rc0 --help`` only reads ``name``/``short_help``/``hidden``/ + ``help`` on each command, which this stub provides eagerly. Anything that + actually inspects or invokes the subcommand (descending into it during + parsing, listing its nested commands for ``introspect``, asking for its + own help) forwards to the real Typer subapp, importing it lazily. + """ + + def __init__(self, name: str, module_path: str, help_text: str) -> None: + super().__init__(name=name, help=help_text, short_help=help_text) + self._module_path = module_path + self._real: click.Group | None = None + + def _resolve(self) -> click.Group: + if self._real is None: + module = importlib.import_module(self._module_path) + real = typer.main.get_command(module.app) + assert isinstance(real, click.Group), f"{self._module_path}.app must be a Typer group" + if self.help and not real.help: + real.help = self.help + self._real = real + return self._real + + def list_commands(self, ctx: click.Context) -> list[str]: + return self._resolve().list_commands(ctx) + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + return self._resolve().get_command(ctx, cmd_name) + + def get_params(self, ctx: click.Context) -> list[click.Parameter]: + return self._resolve().get_params(ctx) + + def make_context( + self, + info_name: str | None, + args: list[str], + parent: click.Context | None = None, + **extra: object, + ) -> click.Context: + real = self._resolve() + assert self.name is not None + if parent is not None and isinstance(parent.command, click.Group): + parent.command.commands[self.name] = real + return real.make_context(info_name, args, parent=parent, **extra) + + def invoke(self, ctx: click.Context) -> object: + return self._resolve().invoke(ctx) + + def get_help(self, ctx: click.Context) -> str: + return self._resolve().get_help(ctx) + + def get_usage(self, ctx: click.Context) -> str: + return self._resolve().get_usage(ctx) + + +class LazyTyperGroup(TyperGroup): + """Top-level group that imports each subcommand module on first access. + + ``rc0 --help`` and ``rc0 --version`` should never pay for httpx, pydantic, + or rich-formatter import time. The stubs in ``self.commands`` expose + enough metadata for Typer's rich help to render without triggering any + subcommand import; ``rc0.commands.X`` is imported only when the user + actually invokes the matching subcommand. + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) # type: ignore[arg-type] + for name, (module_path, help_text) in _LAZY_SUBCOMMANDS.items(): + self.commands.setdefault(name, _LazyStub(name, module_path, help_text)) + + app = typer.Typer( name="rc0", help="The command line for RcodeZero DNS.", no_args_is_help=True, add_completion=True, rich_markup_mode="rich", + cls=LazyTyperGroup, ) -app.add_typer(acme_cmd.app, name="acme", help="Manage ACME DNS-01 challenge records.") -app.add_typer(auth_cmd.app, name="auth", help="Authenticate with the RcodeZero API.") -app.add_typer(config_cmd.app, name="config", help="Read and write rc0 configuration.") -app.add_typer(dnssec_cmd.app, name="dnssec", help="Manage DNSSEC for zones.") -app.add_typer(help_cmd.app, name="help", help="Long-form topic documentation.") -app.add_typer(messages_cmd.app, name="messages", help="Inspect queued account messages.") -app.add_typer(record_cmd.app, name="record", help="Manage RRsets.") -app.add_typer(report_cmd.app, name="report", help="Account-level reports.") -app.add_typer(settings_cmd.app, name="settings", help="Manage account-level settings.") -app.add_typer(skill_cmd.app, name="skill", help="Manage the rc0 Claude Code skill.") -app.add_typer(stats_cmd.app, name="stats", help="Account statistics.") -app.add_typer(tsig_cmd.app, name="tsig", help="Manage TSIG keys.") -app.add_typer(zone_cmd.app, name="zone", help="Manage RcodeZero zones.") - introspect_cmd.register(app) diff --git a/src/rc0/commands/introspect.py b/src/rc0/commands/introspect.py index f4d2cb5..9f4f836 100644 --- a/src/rc0/commands/introspect.py +++ b/src/rc0/commands/introspect.py @@ -14,7 +14,12 @@ def _walk(command: click.Command, path: list[str]) -> list[dict[str, Any]]: if isinstance(command, click.Group): out: list[dict[str, Any]] = [] - for name, sub in command.commands.items(): + # Use list_commands/get_command (not .commands) so lazily-registered + # subcommands in rc0.app.LazyTyperGroup are also materialised. + for name in command.list_commands(ctx=None): # type: ignore[arg-type] + sub = command.get_command(ctx=None, cmd_name=name) # type: ignore[arg-type] + if sub is None: + continue out.extend(_walk(sub, [*path, name])) return out args: list[dict[str, Any]] = [] diff --git a/tests/perf/__init__.py b/tests/perf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/perf/test_startup.py b/tests/perf/test_startup.py new file mode 100644 index 0000000..fd86716 --- /dev/null +++ b/tests/perf/test_startup.py @@ -0,0 +1,72 @@ +"""Binary-startup performance budget. + +Runs the PyInstaller-built ``rc0`` binary repeatedly and asserts the +median wall-clock time stays under the budget. The first run is +discarded because macOS Gatekeeper and the kernel page cache make the +very first invocation an outlier. + +Gated by ``RC0_PERF=1`` (and a built binary at ``dist/rc0/rc0``) so the +default ``pytest`` invocation does not require a build step. +""" + +from __future__ import annotations + +import os +import statistics +import subprocess +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BINARY = REPO_ROOT / "dist" / "rc0" / "rc0" + +# Budget per project goal: cold-cache-warmed startup < 200ms for trivial commands. +BUDGET_SECONDS = 0.200 +RUNS = 7 +WARMUPS = 2 + +pytestmark = pytest.mark.skipif( + os.environ.get("RC0_PERF") != "1" or not BINARY.exists(), + reason="Set RC0_PERF=1 and build the binary (uv run pyinstaller rc0.spec) to run.", +) + + +def _measure(args: list[str]) -> float: + start = time.perf_counter() + result = subprocess.run( # noqa: S603 # invokes our own built binary + [str(BINARY), *args], + capture_output=True, + check=False, + ) + elapsed = time.perf_counter() - start + assert result.returncode == 0, ( + f"rc0 {args} exited {result.returncode}: {result.stderr.decode(errors='replace')}" + ) + return elapsed + + +def _median_over_runs(args: list[str]) -> float: + for _ in range(WARMUPS): + _measure(args) + samples = [_measure(args) for _ in range(RUNS)] + return statistics.median(samples) + + +def test_version_startup_under_budget() -> None: + """`rc0 --version` is the cheapest invocation and must stay under 200ms.""" + median = _median_over_runs(["--version"]) + assert median < BUDGET_SECONDS, ( + f"rc0 --version median startup {median * 1000:.1f}ms " + f"exceeds budget {BUDGET_SECONDS * 1000:.0f}ms" + ) + + +def test_help_startup_under_budget() -> None: + """`rc0 --help` lists every subcommand and must also stay under budget.""" + median = _median_over_runs(["--help"]) + assert median < BUDGET_SECONDS, ( + f"rc0 --help median startup {median * 1000:.1f}ms " + f"exceeds budget {BUDGET_SECONDS * 1000:.0f}ms" + ) diff --git a/tests/unit/test_usage_hint.py b/tests/unit/test_usage_hint.py index 7b6fede..7135aaf 100644 --- a/tests/unit/test_usage_hint.py +++ b/tests/unit/test_usage_hint.py @@ -16,7 +16,8 @@ def _ctx_for(name_path: list[str]) -> click.Context: current: click.Command = root for name in name_path: assert isinstance(current, click.Group) - sub = current.commands[name] + sub = current.get_command(parent, name) + assert sub is not None, f"unknown command in path: {name}" parent = click.Context(sub, info_name=name, parent=parent) current = sub return parent diff --git a/uv.lock b/uv.lock index 7b5c6c9..9fe6902 100644 --- a/uv.lock +++ b/uv.lock @@ -667,7 +667,7 @@ wheels = [ [[package]] name = "rc0-cli" -version = "2.1.1" +version = "2.1.2" source = { editable = "." } dependencies = [ { name = "dnspython" },