Skip to content

0.5.0 (1/5): faster startup, lockstep versions and dependency floors - #160

Closed
dg-coreylweathers wants to merge 10 commits into
release/0.4.1from
release/0.5.0-1-startup
Closed

dg-coreylweathers wants to merge 10 commits into
release/0.4.1from
release/0.5.0-1-startup

Conversation

@dg-coreylweathers

Copy link
Copy Markdown
Contributor

Part 1 of 5 of the 0.5.0 release, stacked on 0.4.1 (#159). Review in order. Merge only after the PR below it has merged, and only after 0.4.1 is released.

Startup work: dg --version and dg mcp start without importing click, commands load from a generated manifest, plugin discovery and help are cached, and config loads without pydantic on the help path. Also adds the competitive benchmark harness, versions every first-party package in lockstep, and raises the dependency floors (click 8.2, caps on runtime majors).

Commits

  • c0d7b67 feat(bench): add competitive benchmark harness with gates
  • ce515ce perf(cli): start dg without importing click for --version and mcp
  • 63020de perf(core): import deepctl_core names lazily
  • ccdb1bc build(release): version every first-party package in lockstep
  • 3c3af56 build(deps): require click 8.2 and cap majors of runtime deps
  • 8bbb9ad feat(cli): load commands from a generated manifest
  • cafb8e2 perf(cli): cache plugin discovery and help output
  • 2d4708f refactor(commands): keep network modules out of command import time
  • feeec4c perf(core): load config without pydantic on the help path
  • 86d1169 perf(cli): render subcommand help from specs

Notes

  • Tests for the full 0.5.0 head (Docker, Linux): Python 3.10 5,616 passed, 91 skipped, 0 failed; Python 3.14 5,617 passed, 90 skipped, 0 failed; lint, types, lock, floors, READMEs and build pass. CI on this PR runs the full matrix.
  • After 0.4.1's release PR merges, this stack is rebased onto main and the lockstep versions are re-baselined to the versions that release set.
  • Merge with a merge commit, not a squash: release-please reads the individual commits.

🤖 Generated with Claude Code

Adds bench/, a Docker-based harness that times dg and a pinned set of
other CLIs and MCP servers, and checks the results against gates.json.

- hyperfine -N timing for --version, --help, subcommand help, cold help
  (page cache dropped), MCP initialize and tools/list, data-path rows
  against a loopback fake API, install size and interpreter floors
- deterministic rows: connect() count under strace plus a
  never-answering proxy, import allowlist and forbidden-module lists,
  source compiles under PYTHONVERBOSE
- rows fail closed: a non-zero exit, unparsed interpreter output or an
  untraceable command records an error, never a value
- every measured container runs with --network none; competitor pins are
  versioned and sha256-verified, base images pinned by digest
- check.py: gates activate at their since version; timing gates are
  report-only until marked blocking; paired base/head rule
  max(10%, 3xMAD, floor), with failed rounds counted as red
- a release profile (dg only, rows that can block), a self-test against
  a fake CLI, and unit tests
- competitive-bench.yml (SHA-pinned actions, read-only permissions, no
  secrets), callable with a ref for a future release job
deepctl.main now imports only sys and os and peeks at argv:

- `dg --version` prints the version constant, byte-identical to 0.4.x
  ("deepctl, version V" for every console script), and exits 0.
- `dg mcp` (stdio, no other options) runs deepctl.mcp._fast, a
  stdlib-only loop that answers initialize, ping and tools/list from
  fixed local text and a local tool table. Every other method
  (server/discover included) gets -32601 locally and never causes a
  connection. The first tools/call after initialize goes to
  deepctl_cmd_mcp.serve_after_handshake, which replays the handshake to
  the local MCP server in deepctl_cmd_mcp.proxy. If the proxy can't
  start, that call gets an isError result and the server keeps running:
  "dg mcp can't start the documentation search: ..." for local causes,
  "dg mcp couldn't reach Deepgram's documentation search: ..." for
  network ones, and 0.4.1's E_KEY_UNTRUSTED + "A person must fix this
  file..." when an agent session left the config file's key unused
  (config notices are flushed to stderr first). stdin EOF exits 0.
- Everything else imports deepctl._cli (the old main.py) and runs Click.

Also:

- deepctl/__init__ is stdlib-only and exports no `main`; new
  deepctl/__main__ makes `python -m deepctl` (and -I) work.
- `dg --version` into a closed pipe or with stdout closed exits quietly.
- main() pops DEEPCTL_LAUNCHER first thing into deepctl.main.LAUNCHER.
- The rich traceback hook (show_locals=False) loads only when an
  exception reaches the top level.
- --timing counts from main()'s first line (startup_imports).
- Past --version, main() marks httpx._main as unavailable, so httpx uses
  its stub CLI and skips pygments and rich.syntax.
- Commands load inside run()'s error handling, not at import. An
  unreadable HOME, plugin venv or cache gives one "WARN: plugins
  unavailable: <reason> (while loading <what>)" line (other plugin
  errors keep their per-plugin message); startup notices skip an
  unreadable cache; `dg --help` and `dg <command> --help` run without a
  readable config (help for a group's subcommands still needs one), and
  the telemetry help notice says off when it can't read one.

Old names (cli, load_commands, helpers) still resolve from deepctl.main
(reads only; patch deepctl._cli). git shows _cli.py as a new file, not a
rename of main.py.
deepctl_core/__init__ maps each public name to its submodule through a
PEP 562 __getattr__, so `import deepctl_core` imports nothing else and
`from deepctl_core import Config` imports only config. A TYPE_CHECKING
block keeps the names visible to mypy, pyright and griffe, __dir__ lists
them, `from deepctl_core import *` still resolves all of __all__ (which
is unchanged), and submodules stay reachable as attributes. The module
__getattr__ hooks exist only at run time, so type checkers flag a
misspelled name instead of typing it Any.

Import moves only, no logic changes:

- client.py: the Deepgram SDK loads on first use (_sdk() binds DGClient,
  DeepgramClientEnvironment and ApiError; a module __getattr__ keeps
  patch("deepctl_core.client.DGClient") working).
- auth.py: httpx and keyring load when the first AuthManager is built,
  and `deepctl_core.auth.keyring` is still the real module, so patches
  of it behave as before; PasswordDeleteError and rich.progress move
  into the functions that use them.
- output.py: console and stderr_console are built, and rich imported, on
  first use; rich renderables are imported where they are rendered.

With the httpx CLI guard in main(), `dg --help` no longer loads
pygments.
Every first-party package (the root plus the 29 packages under
packages/, not the plugin example) now ships at the root's version, and
every first-party dependency is pinned `==` that version.

- release-please has one component for all of them. Its extra-files move
  each package's version and every pin line marked `#
  x-release-please-version`, so a release PR proposes one version and
  one tag. The packages no longer get their own GitHub releases. The
  plugin example stays its own component, with the root excluding its
  path.
- Every package declares `deepctl==` the same version. Without that
  back-edge, `pip install -U deepctl-cmd-listen` upgraded only that
  package and left the CLI and the other packages behind; with it, the
  resolver moves the whole set or refuses.
- deepctl-cmd-plugin now depends on deepctl-cmd-update, which it
  imports.
- scripts/check_dependency_floors.py becomes the lockstep check: it
  fails on any version or pin that differs, a `>=` floor, a missing
  back-edge, a package root does not list, an unmarked pin line, a
  missing workspace source, or a first-party requirement it can't read
  line by line, and `--fix` repairs what it can. The CI job is now
  "Lockstep versions".
- uv.lock is regenerated (uv 0.13.6); generated package READMEs no
  longer list first-party pins.

Mixing an older deepctl-cmd-* with a newer deepctl now conflicts;
upgrade deepctl itself.

Release PRs are re-pinned in two jobs, so no third-party code runs
next to a write credential:

- sync-release-pr has no token and no secrets. It runs the lockstep
  check with --fix, `uv lock` and the check again with uv 0.13.6 and a
  hash-pinned `packaging`, refuses any change outside the pyprojects and
  uv.lock, and hands the result over as a patch artifact.
- push-release-pr runs no Python and no uv. In the `release` environment
  it mints a token from the release GitHub App, scoped to this
  repository with contents: write only, applies the patch on the commit
  it was computed against, checks the patch touches only the pyprojects
  and uv.lock, and pushes. The token reaches git through GIT_CONFIG_*
  environment entries, never a URL, an argument or .git/config, with
  hooks off.

Commits pushed with the App token trigger the PR's workflows, so the
required checks run on the release PR's final head; a GITHUB_TOKEN push
triggered none of them. Both jobs refuse anything but a release-please
branch. The App's client ID is the RELEASE_APP_CLIENT_ID variable and
its private key the RELEASE_APP_PRIVATE_KEY secret of the `release`
environment, which only main may deploy to.

For tests, tests/upgrade/run.sh runs the upgrade matrix behind the lockstep
layout against a local PEP 503 index, in --network none containers:

- lockstep: fresh installs with pip, uv pip, uv tool and pipx resolve
  exactly the 30 first-party dists; `-U` of any one dist from a release
  moves all of them to the next one; upgrades from the field versions
  land on the release with every dist at the same version; and a
  sentry-sdk 3.0 prerelease can't be resolved into an install.
- release-please: runs the release-please version the release workflow
  uses over the tree, offline, and checks that one release PR moves
  every version and pin and nothing else.
- layout: the planned 1.0 layout (relocated code, tombstone packages)
  against the field versions.

CI checks the closure at its floors. Every package declares `deepctl==`
the release version, so any install pulls the whole closure. A new
test.yml job (Python 3.10 and 3.12) builds every package and installs
that closure once with every third-party dependency at the lowest
version the 30 pyprojects together allow (wheels only). In that
environment it runs `pip check`, `dg --help` and every command's and
subcommand's --help, and the unit tests, one suite per process. It also
lists declared floors that sit below what the closure resolves. The job
is part of the "All checks" rollup.
Every package now requires click>=8.2.0. The CLI's own tests rely on
click 8.2 APIs (CliRunner capturing stderr separately, and
click.exceptions.NoArgsIsHelpError), so 8.2 is the oldest click the CLI
is tested against; at 8.1 those suites fail even though the commands
themselves still load.

The root also drops its typer dependency: nothing in deepctl imports
typer, so installing deepctl no longer pulls typer (or shellingham) in.

click, httpx, pydantic, rich, platformdirs, keyring and websockets get
an upper bound below their next major version (click<9, httpx<1,
pydantic<3, rich<16, platformdirs<5, keyring<26, websockets<18), next to
the existing sentry-sdk<3 and deepgram-sdk<8 caps. A new major's
prerelease can then never be resolved into an install, the way a
sentry-sdk 3.0 prerelease breaks every uncapped release. The caps change
no resolved version in uv.lock.
Each built-in command has a fragment in src/deepctl/_manifest.d/ with
its module, class, distribution, help summary, hidden aliases, whether
it is a group and whether it needs auth.
deepctl._manifest_build.assemble() checks the fragments and the rules
across them (a command, alias or tool name may appear once; a
subcommand's group must exist) and builds deepctl/_manifest.json. The
setuptools build_py hook writes that file into every wheel (setuptools
77 or newer); a source tree assembles the fragments in memory instead.
The JSON Schema is docs/contracts/manifest.schema.json. A missing or
invalid manifest ends the run with one line on stderr and exit 1.

The root group is a LazyGroup. Root --help lists the built-ins from the
manifest, and a command's module is imported only when that command runs
or shows its own help. Built-ins load from the manifest's module, never
through an entry point, and the reserved names in plugin_manager, hidden
aliases included, come from the manifest. Plugins are discovered only
for a name that isn't built in, for root --help, for shell completion,
for dg plugin list and for a built-in listed in plugins.allow_override,
with the 0.4.1 checks unchanged. A command whose module can't be
imported reports dependency_missing (exit 1, details.dependency names
the distribution).

Hidden aliases such as debug-audio replace the hyphenated-command
preprocessor and its entry point scan; only the command word is
rewritten. An alias always runs its built-in, unless allow_override
lists it and a plugin registers that name. Any other <group>-<x> runs
<group> <x> when <group> is a built-in group and <x> one of its
subcommands, a plugin's included; an unknown one falls through to plugin
discovery. DEEPCTL_EAGER_LOAD=1 loads every command up front as 0.4.x
did (0.5.x only). DEEPCTL_PROG_NAME sets the program name in usage
lines, python -m deepctl shows dg and leaves the working directory off
sys.path. For people, a usage error starts with Click's usage and Try
lines; in agent mode (CI included) it prints only "ERROR: <message>"
and "HINT: Run <prog> <command> --help to see valid options." The hint
for a global option given after the command names the real program and
command. The dependency_missing agent hint reads "Tell the user <prog>
needs <dependency>. Suggested fix: <hint>". Help runs skip the
background update and AI-CLI checks. jsonschema becomes a test
dependency.

dg mcp's fragment has requires_auth false: it reads the key on each
tool call.
Root --help lists plugin commands, which took an entry point scan and an
import of every plugin on each run. The discovery result is now kept in
the user cache dir and reused while its stamp holds: the mtimes of every
sys.path directory and of the plugin venv under $HOME and the real home,
plus agent mode and the working directory under an agent. A cache hit
lists plugins without importing them, pydantic or Config. When no plugin
is installed at all, a quick entry point check skips discovery.

Only a result the cache can reproduce exactly is kept. A discovery that
wrote to stderr (a skipped plugin, a load error) or let a plugin replace
a built-in runs again next time, so its warnings show on every --help.
Each row stores its summary as the paragraphs Click shows, so a plugin
with multi-line help caches too. Rows are checked when written and again
when read: an invalid or reserved name, or text with a character in
Unicode categories Cc, Cf (other than ZWNJ and ZWJ), Zl, Zp, Co or Cn,
keeps a discovery out of the cache, and a file holding one is ignored.
Built-ins always win.

Under an AI agent the discovery cache lives under the real home's cache
directory, never one HOME or XDG_CACHE_HOME points to.

For the native launcher, a root dg --help run saves its output as <real
cache>/help/<version>-<prog>-<width>-<tty>.txt, with <entry>.key.json
listing every file (path, mtime_ns, size, inode or absent), environment
variable, width and tty state the text depends on, and text_sha256,
which binds the key to the text written before it. That happens only
when the launcher sets DEEPCTL_HELP_CACHE=1 (removed from the
environment at startup), outside an AI agent, for a clean run (exit 0,
nothing on stderr) whose stdout is UTF-8, and only if the key's inputs
didn't change while the help was rendered. No key over 32 files or 64
environment variables, and no entry over 256 KiB, is written. The cache
directory comes from the account (getpwuid or the LocalAppData known
folder), never from HOME or XDG_CACHE_HOME. An unchanged entry isn't
rewritten, and entries for other versions are pruned.
docs/contracts/help-cache.md and help-cache-key.schema.json describe the
contract.

Cache directories are 0700 and files 0600, written through a temp file
in the same directory and renamed into place; stale temp files are
pruned. A cache directory that isn't a real directory owned by this
user, or that its group or others can write, is neither read nor
written, and any error skips the cache.

The help paths also stop importing modules they don't use: yaml in
deepctl_core.output loads only to write YAML, secrets in config only to
name a temp file, and rich.markup in hosts only to print a warning.
dg <cmd> --help imports the command's module, so a module-level import
of asyncio, ssl, httpx, websockets or the SDK was paid by every help and
every run of that command. Those imports move into the functions that
use them: asyncio in listen, mcp, update and debug browser/probe; httpx
in api, debug audio, debug toolkit, init, the update version check and
shared input validation; aiohttp in debug browser/probe; requests and
urllib3 in debug network. Modules whose tests patch asyncio or httpx
through the command module resolve the name lazily. deepctl_cmd_update
exports its names lazily, so the per-run startup check no longer
imports the update command.

A test imports every built-in command module from the manifest in a
fresh interpreter and fails if any of these modules loads, and another
runs dg <cmd> --help for every built-in against the subcommand-help
forbidden list. dg debug <sub> --help still imports httpx, ssl and
keyring through the debug group callback; that check is a strict xfail.
Config(scan_only=True) reads the user file and deepgram.yaml and runs
the same trust, permission, custom-key and project-file checks as a full
load, queueing the same notices, but doesn't build the settings. It
merges a file's data only when the merge could fail, so a file the merge
can't take prints the same warning; the environment and --base-url
steps, which only set values, are skipped. A scan-only Config keeps
config_path and user_config_text for telemetry and raises if its
settings are read.

The settings models move to deepctl_core.config_models and load on first
use; yaml loads when a file is parsed. deepctl_core.config and
_plugin_trust import neither, and the old import paths still work.

setup_output() stores quiet on the lazy console instead of building the
rich Console, which is applied when the console is first used.
Each built-in fragment now carries a "help" block: its command's --help
as data (help text with examples and the agent line, and every parameter
with its type, flags and metavar). deepctl._help_spec turns a Click
command into a spec and a spec back into a callback-less command.

python -m deepctl._manifest_build --write-help writes the blocks from
each command as PluginManager builds it, after rebuilding the command
from the spec and comparing the rendered help at three widths. --check
now also fails when a block no longer matches its command. A command
that uses a Click feature a spec can't carry (a shown default, an
environment variable, a custom class) is refused instead of written
incompletely.

assemble() stays stdlib-only. With a help directory it writes one file
per command to deepctl/_help/ (the build hook does this for every
wheel); the blocks never go into _manifest.json, which root --help and
dg mcp parse. _manifest.help_spec() reads the file, or the fragment in a
source tree. The manifest schema describes the block.

dg <cmd> --help and dg <group> <sub> --help for a built-in leaf now
build the command from its help spec instead of importing it, and the
root callback only scans the config files (Config(scan_only=True)).
Click still renders the help, so width, program name and the telemetry
footer are unchanged, and every config notice prints as before.

The spec path is taken only for exactly those argv shapes, for a leaf
with a spec, when plugins.allow_override lists neither the command nor
its group, outside DEEPCTL_EAGER_LOAD, and for a subcommand only when
every entry point its group would scan is the built-in's own; that scan
reads every distribution, so a "yes" is kept next to the discovery cache
for the same plugin stamp, with the same directory and agent rules.
Group help, a command without a spec and everything else take the normal
path. The spec path never writes a help cache entry.

Intended changes: a built-in whose package can't be imported still
prints its help (running it still reports dependency_missing), and no
command module is imported for a leaf's help.

dg debug <sub> --help with an unreadable HOME now exits 0, lazy or eager:
Click runs the debug group's callback before the subcommand
parses --help, and that callback built a Config, which failed. When the
root found no readable config for a --help run, a group callback now
leaves the subcommand to show its help (a subcommand that runs still
fails on its own Config). The toolkit subcommand also treats an
unreadable cache as an empty one instead of failing to load.

New tests compare spec help with the lazy path and with eager loading
across every leaf, width, output environment and config fixture.

CI now runs the debug-toolkit package tests.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Consolidated into #165, which has all 74 commits of 0.5.0 in one PR.

@dg-coreylweathers
dg-coreylweathers deleted the release/0.5.0-1-startup branch November 7, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant