Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packaging/pyinstaller-hooks/hook-rc0.py
Original file line number Diff line number Diff line change
@@ -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")
125 changes: 98 additions & 27 deletions src/rc0/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import importlib
import logging
import os
import platform
Expand All @@ -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)


Expand Down
7 changes: 6 additions & 1 deletion src/rc0/commands/introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down
Empty file added tests/perf/__init__.py
Empty file.
72 changes: 72 additions & 0 deletions tests/perf/test_startup.py
Original file line number Diff line number Diff line change
@@ -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"
)
3 changes: 2 additions & 1 deletion tests/unit/test_usage_hint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading