Skip to content

feat(presets): let a preset declare a required extension - #4250

Open
Yash-Chindam wants to merge 3 commits into
github:mainfrom
Yash-Chindam:feat/4231-preset-extension-dependency
Open

feat(presets): let a preset declare a required extension#4250
Yash-Chindam wants to merge 3 commits into
github:mainfrom
Yash-Chindam:feat/4231-preset-extension-dependency

Conversation

@Yash-Chindam

@Yash-Chindam Yash-Chindam commented Aug 21, 2026

Copy link
Copy Markdown

Description

Closes #4231.

A preset whose command overrides call into an extension is inert without it — but the overrides fall through to the core workflow, so nothing errors. The feature just silently does less than the user expects, with nothing in the install output pointing at the cause. Until now the only place that dependency could be stated was the README, which fails exactly the user who did not read it.

Three community presets already declare requires.extensions in catalog.community.json (aide-in-place, inventory-alignment, mde), and the preset submission template already collects the field — but preset.yml had no supported key for it and nothing read it. This adds the manifest field and the install-time check.

What changed

Schema. preset.yml accepts an optional requires.extensions, taking either a bare id or a mapping:

requires:
  speckit_version: ">=0.9.0"
  extensions:
    - "companion-extension"        # required, any version
    - id: "other-extension"
      version: ">=1.2.0"           # optional PEP 440 specifier
      required: false              # optional, defaults to true

Validation. Follows the requires.speckit_version strictness established by #3980, for the same reason: an unvalidated value reaches re.match or SpecifierSet later and surfaces as a bare TypeError that no caller handles as a malformed manifest. A non-list, a member that is neither string nor mapping, a missing or non-string or badly-shaped id, a non-string/blank/unparseable version, and a non-boolean required each raise PresetValidationError naming the offending index.

Install-time check. PresetManager.find_unmet_extension_dependencies() reports rather than raises. specify preset add warns once per unsatisfied dependency:

!  This preset depends on extensions that are not satisfied:
    speckit-inventory 0.1.0 does not satisfy >=9.0.0
      Install with: specify extension add speckit-inventory

The preset is installed and safe to use; the parts that rely on these
extensions will do nothing until they are present.

The check sits at the single point where the --dev, --from, and catalog paths converge, so all three behave identically rather than drifting.

Design decisions

These follow the positions I set out in the issue against the assessment's carried-forward questions. Each is easy to change if you'd rather go the other way.

  • Warn, not fail. These presets are written to degrade safely, and three catalog entries already declare the dependency — hard-failing would break installs that work today. The required flag leaves the door open for an opt-in hard-fail later.
  • Version constraints included in v1. The mapping form has to be parsed and validated anyway to accept version, so the incremental cost is the comparison itself. Deferring it would ship a field that validates but is silently ignored — the same shape as the problem this issue is about.
  • Preset side only; extension.yml left alone. It has the identical limitation, but no extension in the catalog declares a dependency on another extension, so there's no demonstrated need. Happy to mirror it here or in a follow-up.
  • Manifest authoritative, catalog a mirror. The catalog isn't consulted for --dev or --from <url> installs, so the manifest is the only copy present on every path. Documented in PUBLISHING.md rather than mechanically reconciled; validating the catalog field against the packaged manifest seems better as its own change against the add-community-preset workflow.

The field is optional, so every existing preset stays valid and silent.

Worth flagging

The warning will fire for nobody on day one. The three presets carrying requires.extensions do so only in their catalog entries, not in their packaged preset.yml. I maintain inventory-alignment and will add it there; the other two need their authors. Not a blocker, but the feature starts with no live coverage.

Testing

Automated

tests/test_presets.py: 624 passed, 8 failed. All 8 failures are WinError 1314 symlink-privilege failures from my Windows environment and reproduce identically on main with this branch stashed. tests/test_extensions.py + tests/test_extension_registration.py: 540 passed, 2 failed, same symlink cause.

22 new tests:

  • requires.extensions absent stays valid and reports no dependencies
  • both declaration forms normalize to the same shape
  • 12 parametrized malformed-input cases (not-a-list, bad member type, missing/non-string/bad-pattern id, non-string/blank/unparseable version, non-boolean required)
  • dependency missing / installed / version satisfied / version unsatisfied
  • required: false never reported
  • registry entry with an unusable version is not invented into a mismatch
  • multiple dependencies evaluated independently

Manual

Per the mapping rules, src/specify_cli/*.py → test the affected CLI command. This changes specify preset add; it is not an init/scaffolding change, so no slash command is affected.

Agent: n/a (CLI change) | OS/Shell: Windows 11 / Git Bash, Python 3.11.14

Command tested Notes
specify preset add --dev Dependency missing → warning naming the extension and install command. Preset still installs.
specify preset add --dev Extension installed, no constraint → no warning.
specify preset add --dev >=0.1.0 against installed 0.1.0 → no warning.
specify preset add --dev >=9.0.0 against installed 0.1.0 → warning showing both versions.
specify preset add --dev Preset declaring nothing → unchanged output.
specify preset add <id> Bundled preset (lean) via the preset_id branch → installs clean, no warning, no regression.
specify preset add --from <url> Covered by the existing test_preset_add_from_url_reads_in_bounded_chunks, which executes the new call site on the --from path.

All three install branches are therefore exercised, which is what the shared call site is meant to guarantee. Re-verified after rebasing onto main at 1.0.1.dev0.

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

Filed by @Yash-Chindam. This change was written by Claude Code (model: Claude Opus 5), acting autonomously on my behalf — code generation, tests, and this description, not just comments. The commit carries an Assisted-by: Claude Code (model: Claude Opus 5, autonomous) trailer.

Worth recording one thing it caught in its own work: the first version of find_unmet_extension_dependencies() broke test_preset_add_from_url_reads_in_bounded_chunks, which passes a duck-typed SimpleNamespace manifest with no requires_extensions attribute. That is a real robustness gap rather than a test artifact — the method is public and reachable with a hand-built manifest, the same case check_compatibility() already guards against — so the fix went into the code, not the test.

Every check in the Testing section was executed by Claude Code on my machine at my direction and the results reviewed by me; I am not claiming I re-ran each command by hand. I will disclose agent involvement again in each review-round comment rather than relying on this section to cover them.

A preset whose command overrides call into an extension is inert without it,
but the overrides fall through to the core workflow, so nothing errors -- the
feature just silently does less than the user expects. Until now the only
place that dependency could be stated was the README, which fails exactly the
user who did not read it.

Add an optional requires.extensions to preset.yml, accepting either a bare
extension id or a mapping with an optional version specifier and an optional
required flag. Validation mirrors the requires.speckit_version strictness from
github#3980: a non-list, a member that is neither string nor mapping, a missing or
malformed id, a non-string or unparseable version, and a non-boolean required
each raise PresetValidationError rather than surfacing later as a bare
TypeError from re.match or SpecifierSet.

On `specify preset add`, warn once for each unsatisfied dependency, naming the
extension and the command that installs it. The check runs at the single point
where the --dev, --from, and catalog paths converge, so all three behave the
same. It warns rather than fails: these presets are written to degrade safely,
and three catalog entries already declare the dependency, so failing would
break installs that work today.

The field is optional, so every existing preset stays valid and silent.

Closes github#4231

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds manifest-declared extension dependencies for presets and install-time warnings when requirements are unmet.

Changes:

  • Validates and normalizes requires.extensions.
  • Checks installed extension versions and emits actionable warnings.
  • Documents the schema and adds dependency tests.
Show a summary per file
File Description
src/specify_cli/presets/__init__.py Adds dependency validation and resolution.
src/specify_cli/presets/_commands.py Displays install-time warnings.
tests/test_presets.py Tests validation and dependency checks.
presets/PUBLISHING.md Documents dependency declarations.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/specify_cli/presets/_commands.py Outdated
f"{_escape_markup(dep['installed'])} does not satisfy "
f"{_escape_markup(dep['version'])}"
)
console.print(f" Install with: specify extension add {extension_id}")
Comment on lines +992 to +995
for dep in declared:
metadata = registry.get(dep["id"])
if metadata is None:
unmet.append({**dep, "installed": None, "reason": "missing"})

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

…sabled

Addresses review feedback on github#4250.

`specify extension add <id>` refuses an already-installed extension without
--force, so suggesting it for a version mismatch handed the user a command
that could only fail. Suggest `extension update` for a version mismatch and
`extension enable` for a disabled one, keeping `add` for a genuinely missing
extension.

A disabled extension was also treated as satisfied, because the registry entry
exists. Resolution skips disabled extensions, so the preset stays exactly as
inert as if the extension were absent, with no warning to explain it. Report
it as a distinct "disabled" reason, ahead of any version check -- enabling is
the prerequisite, and the version may be fine once it is.

Also correct the closing line, which said the extensions "will do nothing
until they are present" -- inaccurate for a disabled extension, which is
present.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)
@Yash-Chindam

Copy link
Copy Markdown
Author

Both findings addressed in 853e67d. I verified each against the code before changing anything, and both were correct.

Remediation command didn't match the reason. Confirmed: install_from_directory raises Extension '<id>' is already installed without --force, so specify extension add <id> could only fail for a version mismatch. Reproduced it directly:

Error: Extension 'speckit-inventory' is already installed. Use 'specify
extension remove speckit-inventory' first, or retry with --force to overwrite.

The remedy now follows the reason — add for missing, update for a version mismatch, enable for a disabled one — and the label changed from "Install with:" to "Fix with:", since two of the three are no longer installs.

Disabled extensions were treated as satisfied. Also confirmed, and the consequence is worse than a missed warning: _collect_extension_layers skips disabled extensions (presets/__init__.py:5337, matching extensions/__init__.py:1000), so the preset is exactly as inert as if the extension were absent, while the registry entry made the check report success. Now reported as a distinct disabled reason, ordered ahead of the version check — enabling is the prerequisite, and the version may well be fine once it is.

End-to-end, with the extension installed but disabled:

!  This preset depends on extensions that are not satisfied:
    speckit-inventory is installed but disabled
      Fix with: specify extension enable speckit-inventory

Running that suggestion enables the extension, and re-installing the preset is then silent.

One thing I changed beyond the two comments: the closing line read "will do nothing until they are present", which is wrong for a disabled extension that is present. It now reads "until this is resolved".

Two new tests cover the disabled case and its ordering against a version mismatch, and the multi-dependency test now mixes present, absent, disabled, and optional. tests/test_presets.py: 626 passed, 8 failed — all 8 are WinError 1314 symlink-privilege failures from my Windows environment that reproduce on main with this branch stashed.

The four CI workflows are still showing action_required, so they have not run yet — could you approve them when you get a chance?

Disclosure: this comment and 853e67d were written by Claude Code (model: Claude Opus 5), acting autonomously on behalf of @Yash-Chindam. The commit carries an Assisted-by: trailer.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/presets/_commands.py Outdated
f"{_escape_markup(dep['installed'])} does not satisfy "
f"{_escape_markup(dep['version'])}"
)
remedy = f"specify extension update {extension_id}"
@mnriem

mnriem commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Assisted-by: ChatGPT (model: GPT-5, supervised)
@Yash-Chindam

Copy link
Copy Markdown
Author

The remaining Copilot finding is addressed in 972cb57. I checked the version-constraint behavior before changing the remediation text.

Version remediation could over-promise. Confirmed: the manifest accepts general PEP 440 constraints such as <2, ==1.2, exclusions, and possible downgrades, while extension update only moves forward to the catalog release. The warning now tells users to install a release satisfying the declared constraint, without claiming that extension update will resolve every valid constraint.

Regression coverage. Added a focused test that verifies an upper-bound mismatch does not suggest specify extension update and instead reports the required constraint explicitly.

The focused preset dependency tests pass (3 passed), and Ruff reports no lint errors. The full preset-file run was also attempted; its unrelated failures are the existing Windows symlink-privilege cases and Typer compatibility failures documented in the PR, with the new test passing.

Disclosure: this comment and 972cb57 were written by ChatGPT (model: GPT-5), acting under the direction of @Yash-Chindam. The commit carries an Assisted-by: trailer.

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.

[Feature]: Let a preset declare a required extension in requires.extensions

3 participants