From fac89f8d1e745e4e563743c01db3fc5bddd70e72 Mon Sep 17 00:00:00 2001 From: Volv G Date: Tue, 10 Nov 2026 15:23:27 -0800 Subject: [PATCH] Apply resolve-config version constraints to digest pins and real name lookups A resolve-config entry's `version` constraint was ignored for `digest` entries, including one inherited from `_defaults`: the pinned component (or its deprecation successor) shipped whatever its version. For `name` entries the constraint never matched anything against a real server, because it was checked against a version field the published-components listing does not return; the version only exists in the component spec. Constraints are now checked against the spec's own version (metadata.annotations.version, then top-level version): - name: only candidates whose spec satisfies the constraint are considered, and the highest is chosen, as before. - digest: deprecation successors are followed only while they satisfy the constraint. Starting at the pin, links that do not satisfy are passed until one does, and the walk stops before a successor that would leave the range, so a deprecated pin or intermediate successor can ship (1.0 -> 1.5 -> 2.1 with "<2" ships 1.5). With upgrade_deprecated=False only the pin is checked. A custom digest resolver's result is checked, not replaced. A mismatch is a miss: the entry's local source or the next entry is tried, and if nothing matches hydration reports the component as not found. A digest whose specs are all missing is still an error, as without a constraint. A spec without a version never satisfies a constraint. TangleApiClient.resolve_digest takes an optional `accept` predicate for the constrained walk; without it the result is unchanged. --- README.md | 2 + packages/tangle-cli/src/tangle_cli/client.py | 50 ++- .../src/tangle_cli/pipeline_hydrator.py | 114 +++++- ...test_resolve_config_version_constraints.py | 343 ++++++++++++++++++ tests/test_symbolic_publisher_hydration.py | 9 +- 5 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 tests/test_resolve_config_version_constraints.py diff --git a/README.md b/README.md index 0c7f48d..6767f95 100644 --- a/README.md +++ b/README.md @@ -1538,6 +1538,8 @@ The dehydrator's explicit `digest` and `name` choices emit exactly this shape in Each dehydration call starts with fresh extraction state. When one dehydrator instance is reused for several outputs, each output writes its own local copies and never references another output's bundle. +An entry's `version` constraint (for example `">=2.0"` or `">=1.0,<2"`, possibly inherited from the config's `_defaults`) is checked against the version in the component's spec: `metadata.annotations.version`, else top-level `version`. Published-component listings carry no version. For `name`, only candidates whose spec satisfies the constraint are considered, and the highest of them is chosen. For `digest`, deprecation successors are followed only while they satisfy the constraint: starting at the pin, links that don't satisfy are passed until one does, and the walk stops before a successor that would leave the range. So the deprecated pin itself or an intermediate successor may ship: for a chain 1.0 → 1.5 → 2.1 and `"<2"` that is 1.5, and for 1.0 → 3.0 → 1.5 it is 1.0. Only the pin's `superseded_by` chain is considered; no new name or version search is added. With `PipelineHydrator(upgrade_deprecated=False)` successors are not followed, so only the pin itself is checked. A spec without a version never satisfies one. Either way a mismatch is a miss: the entry's local source or the next entry is tried, and if nothing matches, hydration reports the component as not found. A constraint with no parseable part is an error. The constraint applies to the library side (`digest`/`name`) only. A local source is not version-checked and, as before, wins if its version is higher. + ### Pipeline run hooks `packages/tangle-cli/src/tangle_cli/pipeline_runs.py` defines `PipelineRunHooks`, passed into `PipelineRunManager`. Subclass it to customize submit/load/wait/log behavior: diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index 0d7631b..200a16e 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -11,10 +11,10 @@ import sys import threading import time -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping from dataclasses import asdict, is_dataclass from email.utils import parsedate_to_datetime -from typing import Any +from typing import Any, overload from urllib.parse import quote, urljoin, urlparse, urlsplit, urlunsplit import requests @@ -988,11 +988,25 @@ def get_component_spec(self, digest: str) -> ComponentSpec: return ComponentSpec.from_dict(_to_plain(self.components_get(digest))) - def resolve_digest(self, digest: str) -> str: - """Resolve a component digest/name, following deprecation successors.""" + @overload + def resolve_digest(self, digest: str, accept: None = None) -> str: ... + + @overload + def resolve_digest(self, digest: str, accept: Callable[[str], bool]) -> str | None: ... + + def resolve_digest( + self, digest: str, accept: Callable[[str], bool] | None = None + ) -> str | None: + """Resolve a component digest/name, following deprecation successors. + + With ``accept``, return the last digest on the way that it accepts, + stopping before a successor it rejects once one was accepted, or None + if it accepts none. + """ current = digest seen: set[str] = set() + accepted: str | None = None while current not in seen: seen.add(current) @@ -1002,18 +1016,22 @@ def resolve_digest(self, digest: str) -> str: include_deprecated=True, name_substring=current, ) - if len(matches) != 1: - return current - - component = matches[0] - resolved = str(component.get("digest") or current) - successor = component.get("superseded_by") - if component.get("deprecated") and successor: - current = str(successor) - continue - return resolved - - return current + resolved, successor = current, None + if len(matches) == 1: + component = matches[0] + resolved = str(component.get("digest") or current) + if component.get("deprecated"): + successor = component.get("superseded_by") + if accept is not None: + if accept(resolved): + accepted = resolved + elif accepted is not None: + return accepted + if not successor: + return resolved if accept is None else accepted + current = str(successor) + + return current if accept is None else accepted def _published_component_rows( self, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py index 47cc15a..e3aa411 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py @@ -580,11 +580,17 @@ def _fetch_component_by_digest( def _find_latest_version_component( self, components: list[ComponentInfo], + specs: Mapping[str, dict[str, Any]] | None = None, ) -> tuple[str, dict[str, Any]]: - """Find the component with the highest version from a list.""" + """Find the component with the highest version from a list. + + ``specs`` holds specs already fetched for this lookup, keyed by digest. + """ client = self._api_client() def _fetch(digest: str) -> tuple[str, dict[str, Any]]: + if specs and digest in specs: + return digest, specs[digest] component = client.get_component_spec(digest) if not component: raise HydrationError(f"Component not found: {digest}") @@ -1039,7 +1045,14 @@ def _resolve_primary( self.log.info(f" Resolve: trying digest={str(value)[:16]}...") elif kind == "url": self.log.info(f" Resolve: trying url={value}") - return self._resolve_registered_component(kind, value, path, base_dir) + constraint = entry.get("version") if kind == "digest" else None + if constraint and self.component_resolvers.get("digest") is _resolve_digest: + return self._fetch_digest_within(str(value), constraint) + result = self._resolve_registered_component(kind, value, path, base_dir) + if constraint and result and not _spec_satisfies_version(result[1], constraint): + self.log.info(f" Resolve: {result[0][:16]}... does not satisfy {constraint}") + return None + return result if "name" in entry: return self._resolve_by_name_with_filters(entry) if any(kind in entry for kind in self._resolve_entry_kinds()): @@ -1189,6 +1202,7 @@ def _resolve_by_name_with_filters( publisher = require_authenticated_user_id(self._api_client()) version_constraint = entry.get("version") required_annotations = entry.get("annotations") + specs: dict[str, dict[str, Any]] = {} search_names = [component_name, add_official_prefix(component_name)] candidates = self._api_client().find_existing_components( @@ -1204,7 +1218,9 @@ def _resolve_by_name_with_filters( if version_constraint: if not _parse_version_constraint(version_constraint): raise HydrationError(f"Invalid version constraint: '{version_constraint}'") - candidates = _filter_by_version_constraint(candidates, version_constraint) + candidates = self._filter_by_spec_version( + candidates, version_constraint, specs + ) if not candidates: self.log.info( f" Resolve: no candidates matching version {version_constraint}" @@ -1219,13 +1235,84 @@ def _resolve_by_name_with_filters( ) return None - found_digest, spec = self._find_latest_version_component(candidates) + found_digest, spec = self._find_latest_version_component(candidates, specs) self.log.info( f" Resolve: matched {spec.get('name', 'unknown')} " f"(digest: {found_digest[:16]}...)" ) return found_digest, spec + def _fetch_digest_within( + self, pin: str, constraint: str + ) -> tuple[str, dict[str, Any]] | None: + """Fetch ``pin``, following its successors only while they satisfy ``constraint``. + + Successors before the first satisfying one are passed through; the walk + stops before one that would leave the range. None if nothing satisfies. + """ + if not _parse_version_constraint(constraint): + raise HydrationError(f"Invalid version constraint: '{constraint}'") + specs: dict[str, dict[str, Any]] = {} + + def accept(digest: str) -> bool: + spec = self._fetch_spec_or_none(digest) + if spec is None: + return False + specs[digest] = spec + return _spec_satisfies_version(spec, constraint) + + if self.upgrade_deprecated: + chosen = self._api_client().resolve_digest(pin, accept=accept) + else: + chosen = pin if accept(pin) else None + if chosen is None: + if not specs: + # Nothing on the walk exists: an error, as without a constraint. + raise HydrationError(f"Component not found: {pin}") + self.log.info(f" Resolve: no version of {pin[:16]}... satisfies {constraint}") + return None + self.log.info( + f" Resolve: {pin[:16]}... -> {chosen[:16]}... version " + f"{utils.get_version_from_data(specs[chosen])} satisfies {constraint}" + ) + return chosen, specs[chosen] + + def _fetch_spec_or_none(self, digest: str) -> dict[str, Any] | None: + """Fetch and normalize a component spec; None only if it is not found.""" + try: + spec_obj = self._api_client().get_component_spec(digest) + except Exception as exc: + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) == 404: + return None + raise + if not spec_obj: + return None + return self.normalize_component_spec(spec_obj) + + def _filter_by_spec_version( + self, + candidates: list[ComponentInfo], + constraint: str, + specs: dict[str, dict[str, Any]], + ) -> list[ComponentInfo]: + """Keep candidates whose component spec satisfies ``constraint``. + + Published-component rows carry no version; it lives only in the spec. + Fetched specs are recorded in ``specs`` so selection can reuse them. + """ + result: list[ComponentInfo] = [] + for candidate in candidates: + if not candidate.digest: + continue + spec = self._fetch_spec_or_none(candidate.digest) + if spec is None: + continue + specs[candidate.digest] = spec + if _spec_satisfies_version(spec, constraint): + result.append(candidate) + return result + def _filter_by_annotations( self, candidates: list[ComponentInfo], @@ -1730,15 +1817,16 @@ def _version_satisfies(version: str, constraint: str) -> bool: return True -def _filter_by_version_constraint( - candidates: list[ComponentInfo], - constraint: str, -) -> list[ComponentInfo]: - """Filter candidates whose version satisfies a constraint string.""" - return [ - c for c in candidates - if c.version and _version_satisfies(c.version, constraint) - ] +def _spec_satisfies_version(spec: Mapping[str, Any], constraint: str) -> bool: + """Check a component spec's own version against a constraint string. + + A spec without a version never satisfies a constraint. An invalid + constraint raises even then. + """ + if not _parse_version_constraint(constraint): + raise HydrationError(f"Invalid version constraint: '{constraint}'") + version = utils.get_version_from_data(spec) + return bool(version) and _version_satisfies(version, constraint) class DehydrateChoice: diff --git a/tests/test_resolve_config_version_constraints.py b/tests/test_resolve_config_version_constraints.py new file mode 100644 index 0000000..3a8aefd --- /dev/null +++ b/tests/test_resolve_config_version_constraints.py @@ -0,0 +1,343 @@ +"""Version constraints in ``resolve://`` configs, against server-shaped data. + +The published-components listing has no ``version`` field: a component's +version exists only in its spec (``GET /api/components/{digest}``). These +tests drive the real client over a fake transport that returns exactly those +shapes, so a filter reading a version off the listing rows cannot pass. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +import pytest +import requests +import yaml + +from tangle_cli.client import TangleApiClient +from tangle_cli.pipeline_hydrator import HydrationError, PipelineHydrator + +_NAME = "orders-loader" +V1 = "sha256:" + "1" * 64 +V2 = "sha256:" + "2" * 64 +UNVERSIONED = "sha256:" + "3" * 64 +GONE = "sha256:" + "4" * 64 # listed, but its spec 404s +OLD = "sha256:" + "5" * 64 # deprecated, superseded by V2 +OLDER = "sha256:" + "6" * 64 # deprecated, superseded by OLD +ZIG_1, ZIG_3, ZIG_15 = ("sha256:" + c * 64 for c in "789") +KEEP = "sha256:" + "a" * 64 # deprecated, superseded by GONE +LOOP_A, LOOP_B = ("sha256:" + c * 64 for c in "bc") +GUARDED = "sha256:" + "d" * 64 # deprecated, superseded by DENIED +DENIED = "sha256:" + "e" * 64 # listed, but reading its spec is forbidden + + +def _published( + digest: str, + version: str | None, + *, + deprecated: bool = False, + superseded_by: str | None = None, +) -> dict[str, Any]: + return { + "digest": digest, + "version": version, + "deprecated": deprecated, + "superseded_by": superseded_by, + } + + +_CATALOG = [ + _published(V1, "1.0.0"), + _published(V2, "2.1.0"), + _published(UNVERSIONED, None), + _published(GONE, "9.0.0"), +] +# Successor chains (superseded_by): OLDER 0.5.0 -> OLD 1.0.0 -> V2 2.1.0, and +# ZIG_1 1.0.0 -> ZIG_3 3.0.0 -> ZIG_15 1.5.0, KEEP 1.0.0 -> GONE (spec 404s), +# the cycle LOOP_A 1.0.0 <-> LOOP_B 3.0.0, and GUARDED 1.0.0 -> DENIED (spec 403s). +_WITH_SUCCESSORS = [ + *_CATALOG, + _published(OLD, "1.0.0", deprecated=True, superseded_by=V2), + _published(OLDER, "0.5.0", deprecated=True, superseded_by=OLD), + _published(ZIG_1, "1.0.0", deprecated=True, superseded_by=ZIG_3), + _published(ZIG_3, "3.0.0", deprecated=True, superseded_by=ZIG_15), + _published(ZIG_15, "1.5.0"), + _published(KEEP, "1.0.0", deprecated=True, superseded_by=GONE), + _published(LOOP_A, "1.0.0", deprecated=True, superseded_by=LOOP_B), + _published(LOOP_B, "3.0.0", deprecated=True, superseded_by=LOOP_A), + _published(GUARDED, "1.0.0", deprecated=True, superseded_by=DENIED), + _published(DENIED, "1.5.0"), +] + + +class _ServerShapedClient(TangleApiClient): + """Real client; only the HTTP exchange is faked.""" + + def __init__(self, catalog: list[dict[str, Any]]) -> None: + super().__init__("https://tangle.test", include_env_credentials=False) + self._catalog = catalog + + def _make_request( + self, + method: str, + path: str, + params: Any = None, + json_data: Any = None, + **kwargs: Any, + ) -> requests.Response: + params = params or {} + if path == "/api/published_components/": + rows = [ + { + # PublishedComponentResponse: no version field. + "digest": c["digest"], + "name": _NAME, + "published_by": "someone", + "deprecated": c["deprecated"], + "superseded_by": c["superseded_by"], + "url": None, + } + for c in self._catalog + if (params.get("include_deprecated") or not c["deprecated"]) + and params.get("digest") in (None, c["digest"]) + and (params.get("name_substring") or "").lower() in _NAME + ] + return _response(200, {"published_components": rows}) + digest = unquote(path.rsplit("/", 1)[-1]) + component = next((c for c in self._catalog if c["digest"] == digest), None) + if digest == DENIED: + return _response(403, {"detail": "Forbidden"}) + if component is None or digest == GONE: + return _response(404, {"detail": "Not found"}) + spec: dict[str, Any] = {"name": _NAME} + if component["version"]: + spec["metadata"] = {"annotations": {"version": component["version"]}} + return _response(200, {"digest": digest, "text": yaml.safe_dump(spec)}) + + +def _response(status: int, payload: Any) -> requests.Response: + response = requests.Response() + response.status_code = status + response.reason = {200: "OK", 403: "Forbidden"}.get(status, "Not Found") + response.url = "https://tangle.test/api" + response.headers["Content-Type"] = "application/json" + response._content = json.dumps(payload).encode() + return response + + +def _resolve_ref( + tmp_path: Path, + config: dict[str, Any], + *, + catalog: list[dict[str, Any]] = _CATALOG, + **hydrator_options: Any, +) -> dict[str, Any]: + """Resolve one task through ``resolve://...#c`` and return its componentRef.""" + (tmp_path / "components.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") + pipeline = { + "name": "p", + "implementation": { + "graph": {"tasks": {"t": {"componentRef": {"url": "resolve://./components.yaml#c"}}}} + }, + } + hydrator = PipelineHydrator(client=_ServerShapedClient(catalog), **hydrator_options) + resolved = hydrator.resolve_components(pipeline, base_dir=tmp_path) + return resolved["implementation"]["graph"]["tasks"]["t"]["componentRef"] + + +def _hydrate( + tmp_path: Path, + config: dict[str, Any], + *, + catalog: list[dict[str, Any]] = _CATALOG, + **hydrator_options: Any, +) -> str: + """Return the digest that would ship.""" + return _resolve_ref(tmp_path, config, catalog=catalog, **hydrator_options)["digest"] + + +def _write_local(tmp_path: Path, version: str) -> None: + local = { + "name": _NAME, + "metadata": {"annotations": {"version": version}}, + "implementation": {"container": {"image": "python:3.12"}}, + } + (tmp_path / "local.yaml").write_text(yaml.safe_dump(local), encoding="utf-8") + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + # Name entries: candidates are filtered on their spec's version. + pytest.param({"c": {"name": _NAME}}, V2, id="name-no-constraint-latest"), + pytest.param({"c": {"name": _NAME, "version": ">=1.0"}}, V2, id="name-compatible-latest"), + pytest.param({"c": {"name": _NAME, "version": "<2"}}, V1, id="name-compatible-older"), + pytest.param({"c": {"name": _NAME, "version": "==2.1.0"}}, V2, id="name-exact"), + pytest.param( + {"_defaults": {"version": "<2"}, "c": {"name": _NAME}}, + V1, + id="name-defaults-version-inherited", + ), + # Versionless and 404ing candidates never match a constraint. + pytest.param({"c": {"name": _NAME, "version": ">=3"}}, None, id="name-no-match"), + # Digest entries: the pinned spec must satisfy the constraint. + pytest.param({"c": {"digest": V1}}, V1, id="digest-no-constraint"), + pytest.param({"c": {"digest": V1, "version": "<2"}}, V1, id="digest-compatible"), + pytest.param({"c": {"digest": V1, "version": ">=2"}}, None, id="digest-incompatible"), + pytest.param( + {"c": {"digest": UNVERSIONED, "version": ">=0"}}, None, id="digest-versionless" + ), + pytest.param( + {"_defaults": {"version": ">=2"}, "c": {"digest": V1}}, + None, + id="digest-defaults-version-inherited", + ), + # A mismatching digest is a miss, not a cue to search by name. + pytest.param( + {"c": {"digest": V1, "name": _NAME, "version": ">=2"}}, + None, + id="digest-mismatch-does-not-search-name", + ), + # A miss falls through to the next configured entry. + pytest.param( + {"c": [{"name": _NAME, "version": ">=3"}, {"name": _NAME, "version": "<2"}]}, + V1, + id="name-miss-falls-through", + ), + pytest.param( + {"c": [{"digest": V1, "version": ">=2"}, {"name": _NAME, "version": ">=2"}]}, + V2, + id="digest-miss-falls-through", + ), + ], +) +def test_version_constraint(tmp_path: Path, config: dict[str, Any], expected: str | None) -> None: + if expected is None: + with pytest.raises(HydrationError, match="Component not found"): + _hydrate(tmp_path, config) + else: + assert _hydrate(tmp_path, config) == expected + + +@pytest.mark.parametrize( + ("pin", "constraint", "upgrade_deprecated", "expected"), + [ + pytest.param(OLD, None, True, V2, id="unconstrained-upgrades"), + pytest.param(OLD, ">=2", True, V2, id="successor-satisfies"), + pytest.param(OLD, "<2", True, OLD, id="only-deprecated-pin-satisfies"), + pytest.param(OLDER, ">=1,<2", True, OLD, id="passes-out-of-range-pin-then-stops"), + pytest.param(OLDER, "<2", True, OLD, id="follows-successors-within-range"), + pytest.param(OLDER, "<1", True, OLDER, id="only-original-pin-satisfies"), + pytest.param(ZIG_1, "<2", True, ZIG_1, id="stops-before-leaving-the-range"), + pytest.param(KEEP, "<2", True, KEEP, id="missing-successor-keeps-accepted"), + pytest.param(LOOP_A, "<5", True, LOOP_B, id="cycle-keeps-last-accepted"), + pytest.param(OLD, ">=3", True, None, id="nothing-in-chain-satisfies"), + pytest.param(OLD, "<2", False, OLD, id="no-upgrade-pin-satisfies"), + pytest.param(OLD, ">=2", False, None, id="no-upgrade-successor-not-considered"), + ], +) +def test_digest_constraint_follows_successors_while_they_satisfy_it( + tmp_path: Path, + pin: str, + constraint: str | None, + upgrade_deprecated: bool, + expected: str | None, +) -> None: + entry: dict[str, Any] = {"digest": pin} + if constraint: + entry["version"] = constraint + options: dict[str, Any] = { + "catalog": _WITH_SUCCESSORS, + "upgrade_deprecated": upgrade_deprecated, + } + if expected is None: + with pytest.raises(HydrationError, match="Component not found"): + _hydrate(tmp_path, {"c": entry}, **options) + else: + assert _hydrate(tmp_path, {"c": entry}, **options) == expected + + +def test_one_pin_resolves_per_entry_constraint_without_cross_talk(tmp_path: Path) -> None: + (tmp_path / "components.yaml").write_text( + yaml.safe_dump({"old": {"digest": OLD, "version": "<2"}, "latest": {"digest": OLD}}), + encoding="utf-8", + ) + tasks = { + name: {"componentRef": {"url": f"resolve://./components.yaml#{name}"}} + for name in ("old", "latest", "old-again") + } + tasks["old-again"]["componentRef"]["url"] = "resolve://./components.yaml#old" + pipeline = {"name": "p", "implementation": {"graph": {"tasks": tasks}}} + + resolved = PipelineHydrator(client=_ServerShapedClient(_WITH_SUCCESSORS)).resolve_components( + pipeline, base_dir=tmp_path + ) + + refs = { + name: task["componentRef"] + for name, task in resolved["implementation"]["graph"]["tasks"].items() + } + shipped = { + name: (ref["digest"], ref["spec"]["metadata"]["annotations"]["version"]) + for name, ref in refs.items() + } + assert shipped == { + "old": (OLD, "1.0.0"), + "latest": (V2, "2.1.0"), + "old-again": (OLD, "1.0.0"), + } + + +def test_digest_miss_falls_back_to_the_entry_local_source(tmp_path: Path) -> None: + # Older than V1, so the local copy ships only because the digest missed. + _write_local(tmp_path, "0.1.0") + + ref = _resolve_ref( + tmp_path, {"c": {"digest": V1, "version": ">=2", "local": "./local.yaml"}} + ) + + assert ref["digest"] != V1 + assert ref["spec"]["metadata"]["annotations"]["version"] == "0.1.0" + + +def test_constrained_digest_that_does_not_exist_is_an_error_not_a_miss(tmp_path: Path) -> None: + _write_local(tmp_path, "0.1.0") + entry = {"digest": "sha256:" + "0" * 64, "version": ">=1", "local": "./local.yaml"} + + with pytest.raises(HydrationError, match="Component not found: sha256:0"): + _hydrate(tmp_path, {"c": entry}) + + +def test_constrained_digest_does_not_hide_a_forbidden_successor(tmp_path: Path) -> None: + _write_local(tmp_path, "0.1.0") + entry = {"digest": GUARDED, "version": "<2", "local": "./local.yaml"} + + with pytest.raises(requests.HTTPError, match="403"): + _hydrate(tmp_path, {"c": entry}, catalog=_WITH_SUCCESSORS) + + +def test_custom_digest_resolver_result_is_checked_not_replaced(tmp_path: Path) -> None: + # The built-in resolver would ship OLD (1.0.0) for "<2"; this one returns 2.1.0. + def resolve_to_newest(hydrator: Any, value: Any, path: str, base_dir: Any) -> Any: + return value, {"name": _NAME, "metadata": {"annotations": {"version": "2.1.0"}}} + + _write_local(tmp_path, "0.1.0") + ref = _resolve_ref( + tmp_path, + {"c": {"digest": OLD, "version": "<2", "local": "./local.yaml"}}, + catalog=_WITH_SUCCESSORS, + component_resolvers={"digest": resolve_to_newest}, + ) + + assert ref["spec"]["metadata"]["annotations"]["version"] == "0.1.0" + + +@pytest.mark.parametrize("locator", [{"name": _NAME}, {"digest": V1}]) +def test_constraint_that_parses_to_nothing_is_rejected( + tmp_path: Path, locator: dict[str, str] +) -> None: + with pytest.raises(HydrationError, match="Invalid version constraint"): + _hydrate(tmp_path, {"c": {**locator, "version": "banana"}}) diff --git a/tests/test_symbolic_publisher_hydration.py b/tests/test_symbolic_publisher_hydration.py index fca903a..01d8279 100644 --- a/tests/test_symbolic_publisher_hydration.py +++ b/tests/test_symbolic_publisher_hydration.py @@ -24,7 +24,8 @@ def _rows(*specs: tuple[str, str | None]) -> list[ComponentInfo]: return [ - ComponentInfo(name="orders-loader", digest=digest, version="1.0", published_by=owner) + # Like real listing rows, these carry no version; it lives in the spec. + ComponentInfo(name="orders-loader", digest=digest, published_by=owner) for digest, owner in specs ] @@ -66,7 +67,11 @@ def find_existing_components(self, *args: Any, **kwargs: Any) -> list[ComponentI return TangleApiClient.find_existing_components(self, *args, **kwargs) def get_component_spec(self, digest: str) -> dict[str, Any]: - return {"name": "orders-loader", "digest": digest} + return { + "name": "orders-loader", + "digest": digest, + "metadata": {"annotations": {"version": "1.0"}}, + } def _entry(**over: Any) -> dict[str, Any]: