Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/specify_cli/bundles/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions tests/specify_cli/bundles/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace

import pytest

Expand All @@ -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
36 changes: 36 additions & 0 deletions tests/specify_cli/bundles/test_command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions tests/specify_cli/bundles/test_command_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 42 additions & 0 deletions tests/specify_cli/bundles/test_primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]