diff --git a/src/specify_cli/bundles/primitives.py b/src/specify_cli/bundles/primitives.py index f910592255..5279c1372c 100644 --- a/src/specify_cli/bundles/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -32,6 +32,20 @@ DEFAULT_PRIORITY = 10 +def _installed_priority(registry, component_id: str, default: int) -> int: + """Return the priority recorded for an installed component, or ``default``. + + A refresh reinstalls the component, so it must carry over a priority the + user set with ``set-priority`` instead of resetting it to the bundle's. + """ + from ..extensions import normalize_priority + + metadata = registry.get(component_id) + if not isinstance(metadata, dict): + return default + return normalize_priority(metadata.get("priority"), default) + + @dataclass(frozen=True) class BundledPatchDetails: kind: str @@ -331,6 +345,8 @@ def _do_install( speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority + if force: + priority = _installed_priority(self._manager.registry, component.id, priority) bundled = _locate_bundled_preset(component.id) if bundled is not None and component.source is None: @@ -422,6 +438,8 @@ def _do_install( speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority + if force: + priority = _installed_priority(self._manager.registry, component.id, priority) bundled = _locate_bundled_extension(component.id) if bundled is not None and component.source is None: diff --git a/tests/specify_cli/bundles/conftest.py b/tests/specify_cli/bundles/conftest.py index f7fa8213cd..394dbeb898 100644 --- a/tests/specify_cli/bundles/conftest.py +++ b/tests/specify_cli/bundles/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import pytest @@ -10,3 +11,53 @@ def project(tmp_path: Path, monkeypatch) -> Path: (tmp_path / ".specify").mkdir() monkeypatch.chdir(tmp_path) return tmp_path + + +@pytest.fixture(params=["extensions", "presets"]) +def bundled_component(project: Path, request): + """Install a bundle whose only component, an extension or a preset, declares priority 10.""" + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.extensions import ExtensionRegistry + from specify_cli.presets import PresetRegistry + from tests.specify_cli.bundles.helpers import valid_manifest_dict, write_manifest + + kind = request.param + cid = "bug" if kind == "extensions" else "lean" + component = {"id": cid, "version": "1.0.2" if kind == "extensions" else "1.0.0"} + if kind == "presets": + component["strategy"] = "replace" + + def declare(priority: int) -> Path: + component["priority"] = priority + return write_manifest(project / "source", valid_manifest_dict(provides={kind: [component]})) + + source = declare(10) + result = CliRunner().invoke(app, ["bundle", "install", str(source), "--offline"]) + assert result.exit_code == 0, result.output + registry_type = ExtensionRegistry if kind == "extensions" else PresetRegistry + + def read_metadata(): + return registry_type(project / ".specify" / kind).get(cid) + + assert read_metadata()["priority"] == 10 + return SimpleNamespace( + kind=kind, cid=cid, source=source, declare=declare, read_metadata=read_metadata + ) + + +@pytest.fixture +def priority_bundle(bundled_component): + """The bundle above, with the component's priority set to 3 by the user.""" + from typer.testing import CliRunner + + from specify_cli import app + + component = bundled_component + result = CliRunner().invoke( + app, [component.kind[:-1], "set-priority", component.cid, "3"] + ) + assert result.exit_code == 0, result.output + assert component.read_metadata()["priority"] == 3 + return component.source, component.read_metadata diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index fb5d8e19fd..2323b7a8de 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -1100,3 +1100,39 @@ def test_install_initializes_uninitialized_project(tmp_path: Path): assert marker.exists() data = json.loads(marker.read_text(encoding="utf-8")) assert "copilot" in json.dumps(data) + + +def test_refresh_preserves_primitive_priority(priority_bundle): + from specify_cli.bundles.records import load_records + + source, read_metadata = priority_bundle + result = runner.invoke(app, ["bundle", "install", str(source), "--offline", "--refresh"]) + + assert result.exit_code == 0, result.output + assert read_metadata()["priority"] == 3 + record = load_records(source.parent.parent)[0] + assert record.required_components[0].priority == 10 + assert record.contributed_components[0].priority == 10 + + +def test_refresh_keeps_a_user_priority_when_the_bundle_priority_changes( + bundled_component, priority_bundle +): + source = bundled_component.declare(5) + result = runner.invoke(app, ["bundle", "install", str(source), "--offline", "--refresh"]) + + assert result.exit_code == 0, result.output + assert bundled_component.read_metadata()["priority"] == 3 + + +def test_refresh_keeps_a_user_priority_after_the_bundle_declares_the_same_value( + bundled_component, priority_bundle +): + for declared in (3, 5): + source = bundled_component.declare(declared) + result = runner.invoke( + app, ["bundle", "install", str(source), "--offline", "--refresh"] + ) + + assert result.exit_code == 0, result.output + assert bundled_component.read_metadata()["priority"] == 3 diff --git a/tests/specify_cli/bundles/test_command_update.py b/tests/specify_cli/bundles/test_command_update.py index 631f17106b..7e7c042b6f 100644 --- a/tests/specify_cli/bundles/test_command_update.py +++ b/tests/specify_cli/bundles/test_command_update.py @@ -352,3 +352,21 @@ def resolve(self, bundle_id, version=None): assert result.exit_code == 0, result.output assert "Requires external tools" not in result.output assert "Updated 'flat' to v1.2.0." in result.output + + +def test_update_preserves_primitive_priority(project, priority_bundle, monkeypatch): + from specify_cli.bundles import command_update + from specify_cli.bundles.sources import _local_manifest_source + + source, read_metadata = priority_bundle + catalog = write_catalog_file(project / "catalog.json", {"demo-bundle": catalog_entry_dict()}) + (project / ".specify/bundle-catalogs.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "catalogs": [{"id": "qa", "url": str(catalog), "priority": 0, "install_policy": "install-allowed"}], + }), encoding="utf-8") + monkeypatch.setattr(command_update, "_download_manifest", lambda *a, **k: _local_manifest_source(str(source))) + + result = runner.invoke(app, ["bundle", "update", "demo-bundle", "--offline"]) + + assert result.exit_code == 0, result.output + assert read_metadata()["priority"] == 3 diff --git a/tests/specify_cli/bundles/test_primitives.py b/tests/specify_cli/bundles/test_primitives.py index dfb0788a20..c1345c7c67 100644 --- a/tests/specify_cli/bundles/test_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -522,6 +522,8 @@ def test_catalog_preset_install_and_refresh_forward_catalog_name( calls = [] class _FakeManager: + registry = PresetRegistry(tmp_path / ".specify/presets") + def install_from_zip(self, *args, **kwargs): calls.append(kwargs) @@ -561,6 +563,8 @@ def test_catalog_extension_install_and_refresh_forward_catalog_and_scaffolding( scaffolded = [] class _FakeManager: + registry = ExtensionRegistry(tmp_path) + def install_from_zip(self, *args, **kwargs): installs.append(kwargs) return SimpleNamespace(id="catalog-extension") @@ -2274,3 +2278,41 @@ def test_extension_pin_refuses_archive_declaring_another_version( manager.install(ComponentRef(kind="extensions", id="my-ext", version="0.4.12")) assert not manager.is_installed(ComponentRef(kind="extensions", id="my-ext")) assert not zip_path.exists() + + +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +@pytest.mark.parametrize("catalog_source", [False, True]) +@pytest.mark.parametrize("refresh", [False, True]) +@pytest.mark.parametrize("stored_priority", [None, 3, True]) +def test_primitive_priority_on_install_and_refresh( + tmp_path, monkeypatch, kind, catalog_source, refresh, stored_priority, +): + import specify_cli._assets as assets + + manager = primitive_manager(kind, tmp_path, allow_network=catalog_source) + if stored_priority is not None: + manager._manager.registry.add("c", {"priority": stored_priority}) + calls = [] + + def install(*args, **kwargs): + calls.append(args[2] if kind == "presets" else kwargs["priority"]) + return SimpleNamespace(id="c") + + if catalog_source: + entry = {"id": "c", "version": "1.0.0", "download_url": "https://example.com/c.zip"} + patch_catalog = _patch_extension_catalog if kind == "extensions" else _patch_preset_catalog + patch_catalog(monkeypatch, entry, tmp_path / "archive.zip", []) + monkeypatch.setattr(manager._manager, "install_from_zip", install) + else: + bundled = _write_manifest(tmp_path / "source", kind[:-1], "1.0.0") + monkeypatch.setattr(assets, f"_locate_bundled_{kind[:-1]}", lambda cid: bundled) + monkeypatch.setattr(manager._manager, "install_from_directory", install) + if kind == "extensions": + monkeypatch.setattr(manager._manager, "scaffold_config", lambda cid: None) + component = ComponentRef(kind, "c", "1.0.0", priority=5) + + (manager.refresh if refresh else manager.install)(component) + + # A malformed stored priority falls back to the bundle's declaration. + expected = 3 if refresh and stored_priority == 3 else 5 + assert calls == [expected]