Conversation
Register every root command by name and import its module only when it is looked up, build services on first ctx.obj access, and resolve the SDK facade in __init__.py via PEP 562 __getattr__. CLI surface, --help output and the public __all__ are unchanged. tests/test_startup_imports.py guards the import budget.
This branch has not been deployed
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.
What
Every
kbagentinvocation used to import all ~37 command modules, all ~36 services (and through them prompt_toolkit, the AI / Data Science / Metastore clients, ...) plus the whole SDK facade -- although one invocation only ever runs one command. This PR makes all three lazy:cli.py): each root command/group is aLazyCommand(name, "module:attr", panel, ...)entry inLAZY_COMMANDS. The root group (LazyRootGroup, passed viatyper.Typer(cls=...)) holds acommandstable in which every name is a key from the start, and the module is imported only when that name is looked up. The Click object is built with Typer's own converters (get_group_from_info/get_command_from_info) from a throwawayadd_typer/commandregistration with exactly the options the eager code passed, so the resulting commands are identical.cli.py):ctx.objis now adictsubclass that constructs a service on its firstctx.obj["x_service"]lookup. Classes are resolved throughcli.<ServiceClass>(module__getattr__), so the test suite's ~1,500mock.patch("keboola_agent_cli.cli.JobService")-style patches keep working unchanged.__init__.py):Client, the result models andJobIdempotencyStoreresolve on first attribute access (PEP 562).__all__is unchanged; aTYPE_CHECKINGblock keeps the names visible to mypy/ty consumers.Why the
commandstable is a dict subclassPlain
list_commands/get_commandoverrides (the Click-docs lazy pattern) would leavegroup.commandsholding only eager entries. Several consumers read.commandsdirectly:telemetry._command_path(every invocation), Typer's typo suggestions (Did you mean 'job'?),test_permissions' registry walk,test_mcp_migration_recipe. Keeping every name as a key and resolving values on[]/get/values/itemsmakes all of them work with no change.Numbers (macOS, Python 3.12, warm cache, median of 5)
python -X importtime -c "import keboola_agent_cli.cli"(cumulative)len(sys.modules)afterimport keboola_agent_cli.cliprompt_toolkitimported byimport keboola_agent_cli.clikbagent job list --helpWhat remains is shared infrastructure nearly every real command needs:
constants(importshttpxforTimeoutobjects, ~30 ms, andimportlib.metadata, ~16 ms),models/pydantic (~23 ms, viaconfig_store),rich,typer.telemetrystill importsservices.base+services.project_service(~3 ms). Those are possible follow-ups, not part of this PR.Parity verification
--helpfor all 370 command paths (walked vialist_commands/get_command), plussl --help,mr list --help,sl model list --help, dumped before and after atCOLUMNS=120: byte-identical.kbagent --helpand barekbagent(non-TTY help) output: byte-identical.--json project list, typo suggestion (kbagent jbo-> "Did you mean 'job'?"),--deny-writeson a group command and on a top-level command (both exit 6PERMISSION_DENIED),--version.make checkgreen: lint, format, ty, skill-check (SKILL.md up to date), command-sync (281 commands), version gates, error codes, sentinel guards, loc-check, full test suite (6908 passed).make gen-command-referencestill sees 281 commands.changelog.pyentry.Guardrail
tests/test_startup_imports.pyruns fresh interpreters (hermetic env: auto-update off, telemetry off, temp config dir) and asserts:import keboola_agent_cli.cliimports no command module, notkeboola_agent_cli.lib, and none of prompt_toolkit / fastapi / uvicorn / starlette / jsonschema;KBAGENT_MODULE_BUDGET = 34kbagent-owned modules (own modules only, so the count is identical across OS and Python versions; ratchets down only);app(["job", "list", "--help"])imports exactly thejobcommand module and no job service;sl/mrimport only their group;kbagent --helpstill resolves every registered command.Reviewer focus
_LazyCommandTableoverrides__getitem__,get,values,items; anything that copies the dict wholesale (dict(table),table.copy()) would seeLazyCommandplaceholders. Nothing in the repo does that today.LazyCommand(...)line inLAZY_COMMANDSinstead of an import +add_typer; a new service means one_SERVICESentry. The list order is the--helporder within each panel.Fixes #801