Repository navigation
0.5.0 (1/5): faster startup, lockstep versions and dependency floors - #160
Closed
dg-coreylweathers wants to merge 10 commits into
Closed
dg-coreylweathers wants to merge 10 commits into
dg-coreylweathers wants to merge 10 commits into
Conversation
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
requested review from
GregHolmes and
deepgram-kiley
as code owners
November 7, 2026 02:35
Contributor
Author
|
Consolidated into #165, which has all 74 commits of 0.5.0 in one PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 --versionanddg mcpstart 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
Notes
🤖 Generated with Claude Code