Skip to content

Commit 989b4e2

Browse files
mnriemCopilot
andcommitted
fix(presets): address catalog release review
Reject duplicate catalog keys on network and cache reads, validate historical extension requirements against the manifest, and list versions from one resolved snapshot. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5c9841f commit 989b4e2

5 files changed

Lines changed: 150 additions & 13 deletions

File tree

‎docs/reference/presets.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ SHA-256 digest; optional `requires` and `provides` apply to that release
212212
instead of inheriting the current release's fields. Other shared metadata,
213213
such as the name and description, is inherited. Version keys must be distinct,
214214
including PEP 440-equivalent spellings, and cannot repeat the current version.
215+
Duplicate JSON keys are rejected before parsing can discard a release record.
216+
Historical `requires.extensions` entries follow the preset manifest format:
217+
extension IDs or mappings with an `id`, optional version constraint, and
218+
optional boolean `required` flag.
215219

216220
```json
217221
{

‎src/specify_cli/presets/_catalog.py‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@
2323
from ._manifest import PresetError, PresetValidationError
2424

2525

26+
def _decode_catalog_json(raw: str | bytes, url: str) -> Any:
27+
"""Reject duplicate keys before JSON parsing discards conflicting records."""
28+
29+
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
30+
result: dict[str, Any] = {}
31+
for key, value in pairs:
32+
if key in result:
33+
raise PresetError(
34+
f"Invalid preset catalog format from {url}: duplicate JSON key '{key}'."
35+
)
36+
result[key] = value
37+
return result
38+
39+
return json.loads(raw, object_pairs_hook=unique_object)
40+
41+
2642
@dataclass
2743
class PresetCatalogEntry:
2844
"""Represents a single entry in the preset catalog stack."""
@@ -426,7 +442,9 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool =
426442
# refreshed.
427443
if not force_refresh and self._is_url_cache_valid(entry.url):
428444
try:
429-
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
445+
cached_data = _decode_catalog_json(
446+
cache_file.read_text(encoding="utf-8"), entry.url
447+
)
430448
self._validate_catalog_payload(cached_data, entry.url)
431449
return cached_data
432450
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
@@ -453,13 +471,14 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
453471
final_url = response.geturl()
454472
if final_url != entry.url:
455473
self._validate_catalog_url(final_url)
456-
catalog_data = json.loads(
474+
catalog_data = _decode_catalog_json(
457475
read_response_limited(
458476
response,
459477
max_bytes=MAX_JSON_CATALOG_BYTES,
460478
error_type=PresetError,
461479
label=f"preset catalog {entry.url}",
462-
)
480+
),
481+
entry.url,
463482
)
464483

465484
self._validate_catalog_payload(catalog_data, entry.url)
@@ -601,8 +620,8 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
601620
self.cache_metadata_file.read_text(encoding="utf-8")
602621
)
603622
if metadata.get("catalog_url") == catalog_url:
604-
cached_data = json.loads(
605-
self.cache_file.read_text(encoding="utf-8")
623+
cached_data = _decode_catalog_json(
624+
self.cache_file.read_text(encoding="utf-8"), catalog_url
606625
)
607626
self._validate_catalog_payload(cached_data, catalog_url)
608627
return cached_data
@@ -624,13 +643,14 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
624643
final_url = response.geturl()
625644
if final_url != catalog_url:
626645
self._validate_catalog_url(final_url)
627-
catalog_data = json.loads(
646+
catalog_data = _decode_catalog_json(
628647
read_response_limited(
629648
response,
630649
max_bytes=MAX_JSON_CATALOG_BYTES,
631650
error_type=PresetError,
632651
label=f"preset catalog {catalog_url}",
633-
)
652+
),
653+
catalog_url,
634654
)
635655

636656
# Validate catalog structure. Reuses the same helper as

‎src/specify_cli/presets/_catalog_versions.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from packaging.version import InvalidVersion, Version
1010

1111
from .._download_security import is_https_or_localhost_http
12-
from ._manifest import PresetError
12+
from ._manifest import PresetError, PresetManifest, PresetValidationError
1313

1414
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
1515
_CURRENT_FIELDS = frozenset(
@@ -101,10 +101,13 @@ def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]:
101101
raise PresetError(
102102
f"Preset '{pack_id}' release '{release_version}' has invalid requires.speckit_version."
103103
) from None
104-
if "extensions" in requires and not isinstance(requires["extensions"], list):
105-
raise PresetError(
106-
f"Preset '{pack_id}' release '{release_version}' has invalid requires.extensions."
107-
)
104+
if "extensions" in requires:
105+
try:
106+
PresetManifest._validate_requires_extensions(requires["extensions"])
107+
except PresetValidationError as exc:
108+
raise PresetError(
109+
f"Preset '{pack_id}' release '{release_version}' has {exc}"
110+
) from exc
108111
if "bundled" in record and not isinstance(record["bundled"], bool):
109112
raise PresetError(
110113
f"Preset '{pack_id}' release '{release_version}' has invalid bundled."

‎src/specify_cli/presets/command_info.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from rich.markup import escape as _escape_markup
77

88
from .._console import console
9+
from ._catalog_versions import available_versions
910
from ._commands import preset_app
1011

1112

@@ -25,7 +26,7 @@ def preset_info(
2526
catalog = PresetCatalog(project_root)
2627
try:
2728
pack_info = catalog.get_pack_info(preset_id)
28-
available = catalog.get_pack_versions(preset_id) if pack_info else []
29+
available = available_versions(pack_info) if pack_info else []
2930
except PresetError as exc:
3031
console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
3132
raise typer.Exit(1) from exc

‎tests/specify_cli/presets/test_catalog_versions.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import hashlib
66
import io
7+
import json
78
import zipfile
9+
from datetime import datetime, timezone
810
from pathlib import Path
911
from unittest.mock import MagicMock, patch
1012

@@ -82,6 +84,62 @@ def _response(data: bytes, url: str) -> MagicMock:
8284
return response
8385

8486

87+
def _duplicate_release_json() -> bytes:
88+
entry = _entry()
89+
payload = json.dumps({"schema_version": "1.0", "presets": {"sample": entry}})
90+
record = f'"1.0.0": {json.dumps(entry["releases"]["1.0.0"])}'
91+
conflicting = {**entry["releases"]["1.0.0"], "download_url": CURRENT_URL}
92+
assert record in payload
93+
return payload.replace(
94+
record, f'{record}, "1.0.0": {json.dumps(conflicting)}', 1
95+
).encode()
96+
97+
98+
@pytest.mark.parametrize("legacy", [False, True], ids=["stack", "single-catalog"])
99+
def test_duplicate_release_key_rejected_from_network(project_dir, legacy):
100+
catalog = PresetCatalog(project_dir)
101+
url = catalog.DEFAULT_CATALOG_URL
102+
entry = PresetCatalogEntry(url, "default", 1, True)
103+
with (
104+
patch.object(catalog, "get_catalog_url", return_value=url),
105+
patch.object(
106+
catalog, "_open_url", return_value=_response(_duplicate_release_json(), url)
107+
),
108+
pytest.raises(PresetError, match="duplicate.*1.0.0"),
109+
):
110+
if legacy:
111+
catalog.fetch_catalog(force_refresh=True)
112+
else:
113+
catalog._fetch_single_catalog(entry, force_refresh=True)
114+
assert not catalog.cache_file.exists()
115+
116+
117+
@pytest.mark.parametrize("legacy", [False, True], ids=["stack", "single-catalog"])
118+
def test_duplicate_release_key_in_cache_refetches(project_dir, legacy):
119+
catalog = PresetCatalog(project_dir)
120+
url = catalog.DEFAULT_CATALOG_URL
121+
entry = PresetCatalogEntry(url, "default", 1, True)
122+
catalog.cache_dir.mkdir(parents=True)
123+
catalog.cache_file.write_bytes(_duplicate_release_json())
124+
catalog.cache_metadata_file.write_text(
125+
json.dumps({
126+
"cached_at": datetime.now(timezone.utc).isoformat(),
127+
"catalog_url": url,
128+
})
129+
)
130+
valid = {"schema_version": "1.0", "presets": {"sample": _entry()}}
131+
with (
132+
patch.object(catalog, "get_catalog_url", return_value=url),
133+
patch.object(
134+
catalog, "_open_url", return_value=_response(json.dumps(valid).encode(), url)
135+
) as opened,
136+
):
137+
result = catalog.fetch_catalog() if legacy else catalog._fetch_single_catalog(entry)
138+
assert result == valid
139+
opened.assert_called_once()
140+
assert json.loads(catalog.cache_file.read_text()) == valid
141+
142+
85143
def test_current_and_exact_selection_keep_current_fields(project_dir):
86144
catalog = PresetCatalog(project_dir)
87145
entry = _entry()
@@ -204,6 +262,42 @@ def test_malformed_history_rejected_even_for_current(project_dir, change, error)
204262
catalog.get_pack_info("sample")
205263

206264

265+
@pytest.mark.parametrize(
266+
"dependencies",
267+
[
268+
[123],
269+
[{}],
270+
[{"id": "dep", "version": 2}],
271+
[{"id": "dep", "required": 0}],
272+
["bad id"],
273+
],
274+
)
275+
def test_historical_release_rejects_malformed_extension_dependencies(
276+
project_dir, dependencies
277+
):
278+
entry = _entry()
279+
entry["releases"]["1.0.0"]["requires"]["extensions"] = dependencies
280+
catalog = PresetCatalog(project_dir)
281+
with (
282+
patch.object(catalog, "_get_merged_packs", return_value={"sample": entry}),
283+
pytest.raises(PresetError, match="requires.extensions"),
284+
):
285+
catalog.get_pack_info("sample")
286+
287+
288+
def test_historical_release_accepts_manifest_extension_dependencies(project_dir):
289+
dependencies = [
290+
"plain-ext",
291+
{"id": "other-ext", "version": ">=1.2", "required": False},
292+
]
293+
entry = _entry()
294+
entry["releases"]["1.0.0"]["requires"]["extensions"] = dependencies
295+
catalog = PresetCatalog(project_dir)
296+
with patch.object(catalog, "_get_merged_packs", return_value={"sample": entry}):
297+
selected = catalog.get_pack_info("sample", "1.0.0")
298+
assert selected["requires"]["extensions"] == dependencies
299+
300+
207301
def test_winning_source_does_not_fall_back_to_lower_release(project_dir):
208302
catalog = PresetCatalog(project_dir)
209303
sources = [
@@ -400,6 +494,21 @@ def open_url(_self, url, **_kwargs):
400494
assert PresetManager(project_dir).get_pack("sample").version == "1.0.0"
401495

402496

497+
def test_cli_versions_use_winning_entry_snapshot(project_dir):
498+
first = {**_entry(), "_install_allowed": False}
499+
second = {"id": "sample", "version": "3.0.0"}
500+
with (
501+
patch.object(Path, "cwd", return_value=project_dir),
502+
patch.object(PresetCatalog, "get_pack_info", side_effect=[first, second]) as lookup,
503+
):
504+
result = CliRunner().invoke(app, ["preset", "info", "sample", "--versions"])
505+
assert result.exit_code == 0, result.output
506+
assert "2.0.0 (current)" in result.output and "1.0.0" in result.output
507+
assert "3.0.0" not in result.output
508+
assert "Discovery only" in result.output
509+
lookup.assert_called_once_with("sample")
510+
511+
403512
def test_cli_rejects_missing_release_and_discovery_without_download(project_dir):
404513
entry = _entry()
405514
with (

0 commit comments

Comments
 (0)