refactor: move extension command handlers to extensions/_commands.py (PR-7/8)#3014
refactor: move extension command handlers to extensions/_commands.py (PR-7/8)#3014darion-yaphet wants to merge 3 commits into
Conversation
5da1bab to
29be5d8
Compare
There was a problem hiding this comment.
Pull request overview
Refactors the Specify CLI by extracting specify extension * and specify extension catalog * command handlers out of the specify_cli/__init__.py monolith into a dedicated extensions/_commands.py module, while converting extensions from a flat module into a package to support the domain-dir layout.
Changes:
- Introduces
src/specify_cli/extensions/_commands.pycontaining the Typer apps (extension,catalog) and all related command handlers, plus aregister(app)entry point. - Converts
extensions.pyintoextensions/__init__.pyand updates intra-package imports accordingly. - Re-attaches the extension command group from
specify_cli/__init__.pyvia_register_extension_cmds(app)to preserve the CLI surface.
Show a summary per file
| File | Description |
|---|---|
| src/specify_cli/extensions/_commands.py | New home for extension / catalog Typer apps and all extension-related CLI handlers (registered via register(app)). |
| src/specify_cli/extensions/init.py | Converts extensions into a package and adjusts relative imports to continue referencing root-package siblings. |
| src/specify_cli/init.py | Removes inline extension command definitions and registers the new extensions command module to keep the same CLI entry points. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 3/3 changed files
- Comments generated: 3
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback and resolve conflicts
50756f8 to
ddcc165
Compare
|
fixed |
| output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) | ||
| cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" | ||
| if cmd_file.exists(): | ||
| backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name |
| }) | ||
|
|
||
| config["catalogs"] = catalogs | ||
| config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") |
| raise typer.Exit(1) | ||
|
|
||
| config["catalogs"] = catalogs | ||
| config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") |
There was a problem hiding this comment.
The principle is sound but doesn't apply here — no bug, no behavioral change, pure code hygiene.
yaml.dump only differs from yaml.safe_dump when it encounters non-basic Python types (custom objects, set, tuple, etc.): the default Dumper emits !!python/object tags
for them, whereas SafeDumper raises instead. That's the real safety argument for safe_dump.
But the data written at this call site is built entirely from basic types — str, int, bool, dict, list:
{"name": str, "url": str, "priority": int,
"install_allowed": bool, "description": str}
For input like this, yaml.dump and yaml.safe_dump produce byte-for-byte identical output — no tags are ever emitted, because there are no non-basic types to tag. So
switching to safe_dump here changes nothing observable: no bug fixed, no output difference, no edge case closed.
It's purely a defensive/consistency nicety, not a correctness fix — and given the project already uses yaml.dump in most places, "consistency" cuts both ways.
| # Default (no flags) lists installed; --all also lists installed. | ||
| # --available alone lists only catalog extensions, not installed. | ||
| show_installed = all_extensions or not available | ||
| show_available = available or all_extensions | ||
|
|
There was a problem hiding this comment.
You're right — this is a genuine behavior change and doesn't belong in a "pure structural move" PR. Thanks for catching it.
For context on why the change exists: the --available / --all flags aren't new. They've existed since the original extension system (#1551, f14a47e), and their help
text has always advertised:
- --available: "Show available extensions from catalog"
- --all: "Show both installed and available"
But the implementation from day one was a stub — passing either flag just printed a static Install an extension: specify extension add hint and never queried
the catalog. The ExtensionCatalog infrastructure was already present in that same #1551 PR; the flags were simply never wired to it. So commit 8c28922 makes the flags
finally do what their help text and the docs have claimed all along.
In other words this is a long-standing dead/misleading-flag fix, not a new feature — but you're correct that it's orthogonal to the refactor and should not ride along
silently under "no behavior change."
Proposed fix: I'll pull 8c28922 out of this refactor PR and ship it separately, with test coverage for list --available / --all (catalog query + installed-ID
filtering + the catalog-unavailable error path). Note that commit also contains an unrelated path-traversal sanitization fix for extension add --from, so I'll split
that into its own change too rather than dropping it. This refactor PR will then be a true structural-only move.
|
Please address Copilot feedback |
…(PR-7/8) Convert the flat extensions.py module into an extensions/ package and extract all extension_app and catalog_app command handlers plus their private helpers (_resolve_installed_extension, _resolve_catalog_extension, _print_extension_info) out of __init__.py into the new extensions/_commands.py, mirroring the domain-dir layout used for presets/_commands.py (PR-6) and integrations/_commands.py (PR-5). - extensions.py -> extensions/__init__.py (pure rename, 99%); intra-module relative imports bumped from `.x` to `..x` since they reference root siblings. - Root helpers (_require_specify_project, _locate_bundled_extension, load_init_options, _display_project_path) are reached through thin shims that re-fetch from the parent package at call time, so test monkeypatching of specify_cli.<helper> keeps working unchanged. - __init__.py drops ~1444 lines (3511 -> 2067); CLI surface preserved via register(app). No behavior change. Full suite failure set is identical before/after (82 pre-existing env failures, 0 new).
…s agents Skills agents (extension == "/SKILL.md") name every command file SKILL.md, each in its own per-command subdir (e.g. speckit-plan/SKILL.md). The update backup keyed the backup path on cmd_file.name alone, so all of an agent's skill files collided onto a single backup path — each shutil.copy2 overwrote the previous one, and rollback restored one skill's content over all the others, corrupting or losing the rest. Mirror the real on-disk layout by using cmd_file.relative_to(commands_dir), keeping each backup path unique. This also makes backed_up_command_files values unique so restore copies the correct content back to each command. Add a regression test asserting two distinct skill files survive a backup -> failed-update -> rollback cycle with their own content.
The catalog add/remove handlers wrote the integration catalog config with yaml.dump. Switch to yaml.safe_dump to align with the SafeDumper used by the presets commands and to refuse emitting !!python/object tags if a non-basic value ever reaches the config dict. Output is unchanged for the current basic-type payload (str/int/bool/dict/ list) — this is a defensive/consistency change, not a behavioral fix.
ddcc165 to
efe0633
Compare
Heads-up on merge orderingI force-pushed this branch to remove the The extracted change now lives in its own PR, #3051, with test coverage. Merge order matters, because #3051 is stacked on this branch:
If #3051 is reviewed before this merges, please ignore the structural-move commit in its diff — it's this PR, and will disappear on rebase. |
Summary
PR-7 of the 8-part effort to break up the
specify_cli/__init__.pymonolith. Continues the domain-dir layout established in PR-5 (integrations/_commands.py) and PR-6 (presets/_commands.py).This PR moves the
extension *andcatalog *command handlers out of__init__.py. Becauseextensionswas a flat module (unlike the already-packagepresets/integrations), it is first converted to a package.Changes
extensions.py→extensions/__init__.py— pure rename (99% identical). The module's intra-file relative imports are bumped from.xto..x, since they reference root-package siblings (.._init_options,..catalogs,..agents,..integrations, andfrom .. importforload_init_options,_print_cli_warning,AGENT_CONFIG,DEFAULT_SKILLS_DIR,_get_skills_dir).extensions/_commands.py— holdsextension_app,catalog_app, all 12 command handlers (list/add/remove/search/info/update/enable/disable/set-priority+ cataloglist/add/remove), the private helpers (_resolve_installed_extension,_resolve_catalog_extension,_print_extension_info), and aregister(app)entry point.__init__.pydrops ~1444 lines (3511 → 2067). The extension command group is re-attached viaregister(app), preserving the CLI surface.Monkeypatch compatibility
Root helpers (
_require_specify_project,_locate_bundled_extension,load_init_options,_display_project_path) are reached through thin shims in_commands.pythat re-fetch from the parent package at call time. This keeps existingspecify_cli.<helper>monkeypatch targets working with no test changes.Verification
extension/catalogcommand tree loads and all--helpsurfaces respond.No behavior change — pure structural move.