From 31fbe60e097bf99181675ca6c4bec8d7adc21d2b Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 18:37:14 +0500 Subject: [PATCH 01/29] chore: drop unused mypy type-ignore in format_output --- src/libtea/_cli_fmt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index fb2ac08..34fe077 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -632,7 +632,7 @@ def format_output(data: object, *, command: str | None = None, console: Console for model_type, formatter in _TYPE_FORMATTERS.items(): if isinstance(data, model_type): - formatter(data, console=c) # type: ignore[operator] + formatter(data, console=c) return # Fallback: render as JSON From e1a74004ec5596860fbc9d4d6184aeb2d2d10ed1 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 18:37:16 +0500 Subject: [PATCH 02/29] feat: track negotiated TEA spec version on TeaClient --- src/libtea/_validation.py | 17 +++-- src/libtea/client.py | 97 ++++++++++++++++++--------- src/libtea/protocols.py | 3 + tests/cli/test_cli.py | 14 ++-- tests/client/test_cache.py | 2 +- tests/client/test_client.py | 56 ++++++++-------- tests/client/test_client_v1.py | 52 ++++++++++++++ tests/client/test_http.py | 18 ++--- tests/client/test_iterators.py | 2 +- tests/conftest.py | 17 ++++- tests/spec_data.py | 49 ++++++++++++++ tests/unit/test_conformance_checks.py | 2 +- tests/unit/test_conformance_plugin.py | 4 +- tests/unit/test_conformance_runner.py | 2 +- tests/unit/test_protocols.py | 2 +- tests/unit/test_validation.py | 32 +++++++++ 16 files changed, 282 insertions(+), 87 deletions(-) create mode 100644 tests/client/test_client_v1.py create mode 100644 tests/spec_data.py diff --git a/src/libtea/_validation.py b/src/libtea/_validation.py index 8ef7e53..903e8fd 100644 --- a/src/libtea/_validation.py +++ b/src/libtea/_validation.py @@ -14,20 +14,26 @@ _M = TypeVar("_M", bound=BaseModel) -def _validate(model_cls: type[_M], data: Any) -> _M: +def _context(legacy: bool) -> dict[str, bool] | None: + # No context means 1.0.0 rules; 0.4.0 compatibility shims key off ``tea_legacy``. + return {"tea_legacy": True} if legacy else None + + +def _validate(model_cls: type[_M], data: Any, *, legacy: bool = False) -> _M: """Validate a JSON-decoded value against a Pydantic model. Wraps :meth:`pydantic.BaseModel.model_validate`, converting any :class:`~pydantic.ValidationError` into :class:`TeaValidationError` - so callers only need to catch the ``TeaError`` hierarchy. + so callers only need to catch the ``TeaError`` hierarchy. With + ``legacy=True`` the model is validated in the 0.4.0 compatibility context. """ try: - return model_cls.model_validate(data) + return model_cls.model_validate(data, context=_context(legacy)) except ValidationError as exc: raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc -def _validate_list(model_cls: type[_M], data: Any) -> list[_M]: +def _validate_list(model_cls: type[_M], data: Any, *, legacy: bool = False) -> list[_M]: """Validate a JSON array where each element conforms to a Pydantic model. Raises :class:`TeaValidationError` if ``data`` is not a list or any @@ -35,8 +41,9 @@ def _validate_list(model_cls: type[_M], data: Any) -> list[_M]: """ if not isinstance(data, list): raise TeaValidationError(f"Expected list for {model_cls.__name__}, got {type(data).__name__}") + context = _context(legacy) try: - return [model_cls.model_validate(item) for item in data] + return [model_cls.model_validate(item, context=context) for item in data] except ValidationError as exc: raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc diff --git a/src/libtea/client.py b/src/libtea/client.py index 9c5c6a8..6e640d9 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -14,6 +14,9 @@ from pathlib import Path from types import TracebackType from typing import Self, TypeVar +from urllib.parse import urlsplit + +from semver import Version from libtea._hashing import WEAK_HASH_ALGORITHMS from libtea._http import TeaHttpClient, probe_endpoint @@ -52,11 +55,34 @@ logger = logging.getLogger("libtea") -TEA_SPEC_VERSION = "0.4.0" +TEA_SPEC_VERSION = "1.0.0" +# Highest first. Major 0 is the legacy (0.4.0) wire format; major 1 is current. +SUPPORTED_SPEC_VERSIONS: tuple[str, ...] = ("1.0.0", "0.4.0") +_SUPPORTED_MAJORS = frozenset(Version.parse(v).major for v in SUPPORTED_SPEC_VERSIONS) _T = TypeVar("_T") +def _resolve_spec_version(base_url: str, explicit: str | None) -> Version: + """Resolve the TEA spec version: explicit argument, then a ``/v`` URL suffix, then the default.""" + candidate = explicit + if candidate is None: + last_segment = urlsplit(base_url).path.rstrip("/").rpartition("/")[2] + if last_segment.startswith("v") and Version.is_valid(last_segment[1:]): + candidate = last_segment[1:] + if candidate is None: + return Version.parse(TEA_SPEC_VERSION) + try: + version = Version.parse(candidate) + except ValueError: + version = None + if version is None or version.major not in _SUPPORTED_MAJORS: + raise ValueError( + f"Unsupported TEA spec version {candidate!r}; supported versions: {', '.join(SUPPORTED_SPEC_VERSIONS)}" + ) + return version + + class TeaClient: """Synchronous client for the Transparency Exchange API (consumer / read-only). @@ -74,6 +100,13 @@ class TeaClient: timeout: Request timeout in seconds (default 30). max_retries: Number of automatic retries on 5xx responses (default 3). backoff_factor: Exponential backoff multiplier between retries (default 0.5). + spec_version: TEA spec SemVer the server speaks. Defaults to the ``/v`` + suffix of ``base_url`` when present, else :data:`TEA_SPEC_VERSION`. Major 0 + (``0.4.0``) selects the legacy wire format; major 1 selects 1.0.0. + + Raises: + ValueError: If the resolved spec version is not valid SemVer or its major + version is not supported. """ def __init__( @@ -87,7 +120,11 @@ def __init__( backoff_factor: float = 0.5, allow_private_ips: bool = False, cache_ttl: float | None = None, + spec_version: str | None = None, ): + resolved = _resolve_spec_version(base_url, spec_version) + self.spec_version: str = str(resolved) + self.is_legacy: bool = resolved.major == 0 self._allow_private_ips = allow_private_ips self._http = TeaHttpClient( base_url=base_url, @@ -107,7 +144,7 @@ def from_well_known( token: str | None = None, basic_auth: tuple[str, str] | None = None, timeout: float = 30.0, - version: str = TEA_SPEC_VERSION, + version: str = "0.4.0", scheme: str = "https", port: int | None = None, max_retries: int = 3, @@ -127,7 +164,7 @@ def from_well_known( token: Optional bearer token. basic_auth: Optional ``(username, password)`` tuple. timeout: Request timeout in seconds (default 30). - version: TEA spec SemVer to match against (default: library's built-in version). + version: TEA spec SemVer to match against (default ``0.4.0``). scheme: URL scheme for discovery — ``"https"`` (default) or ``"http"``. port: Optional port for ``.well-known`` resolution. max_retries: Retry count on 5xx (default 3). @@ -194,7 +231,7 @@ def discover(self, tei: str) -> list[DiscoveryInfo]: """ # requests auto-encodes query params — do NOT pre-encode with quote() data = self._http.get_json("/discovery", params={"tei": tei}) - return _validate_list(DiscoveryInfo, data) + return _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) # --- Products --- @@ -214,7 +251,7 @@ def list_products(self, *, page_offset: int = 0, page_size: int = 100) -> Pagina "/products", params={"pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedProductResponse, data) + return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) def search_products( self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 @@ -236,7 +273,7 @@ def search_products( "/products", params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedProductResponse, data) + return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) def get_product(self, uuid: str) -> Product: """Get a TEA product by UUID. @@ -248,7 +285,7 @@ def get_product(self, uuid: str) -> Product: The product with its identifiers. """ data = self._http.get_json(f"/product/{_validate_path_segment(uuid)}") - return _validate(Product, data) + return _validate(Product, data, legacy=self.is_legacy) def get_product_releases( self, uuid: str, *, page_offset: int = 0, page_size: int = 100 @@ -269,7 +306,7 @@ def get_product_releases( f"/product/{_validate_path_segment(uuid)}/releases", params={"pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) # --- Product Releases --- @@ -289,7 +326,7 @@ def list_product_releases(self, *, page_offset: int = 0, page_size: int = 100) - "/productReleases", params={"pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) def search_product_releases( self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 @@ -311,7 +348,7 @@ def search_product_releases( "/productReleases", params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) def get_product_release(self, uuid: str) -> ProductRelease: """Get a product release by UUID. @@ -323,7 +360,7 @@ def get_product_release(self, uuid: str) -> ProductRelease: The product release with its component references. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}") - return _validate(ProductRelease, data) + return _validate(ProductRelease, data, legacy=self.is_legacy) def get_product_release_collection_latest(self, uuid: str) -> Collection: """Get the latest collection for a product release. @@ -335,7 +372,7 @@ def get_product_release_collection_latest(self, uuid: str) -> Collection: The latest collection with its artifacts. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collection/latest") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def get_product_release_collections(self, uuid: str) -> list[Collection]: """Get all collection versions for a product release. @@ -347,7 +384,7 @@ def get_product_release_collections(self, uuid: str) -> list[Collection]: List of all collection versions. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data) + return _validate_list(Collection, data, legacy=self.is_legacy) def get_product_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a product release. @@ -361,7 +398,7 @@ def get_product_release_collection(self, uuid: str, version: int) -> Collection: """ _validate_collection_version(version) data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collection/{version}") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) # --- Components --- @@ -375,7 +412,7 @@ def get_component(self, uuid: str) -> Component: The component with its identifiers. """ data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}") - return _validate(Component, data) + return _validate(Component, data, legacy=self.is_legacy) def get_component_releases(self, uuid: str) -> list[Release]: """Get all releases for a component. @@ -389,7 +426,7 @@ def get_component_releases(self, uuid: str) -> list[Release]: List of component releases. """ data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}/releases") - return _validate_list(Release, data) + return _validate_list(Release, data, legacy=self.is_legacy) def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedComponentResponse: """List all components (no identifier filter). @@ -407,7 +444,7 @@ def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> Pagi "/components", params={"pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedComponentResponse, data) + return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) def search_components( self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 @@ -429,7 +466,7 @@ def search_components( "/components", params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedComponentResponse, data) + return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) # --- Component Releases --- @@ -443,7 +480,7 @@ def get_component_release(self, uuid: str) -> ComponentReleaseWithCollection: The release bundled with its latest collection of artifacts. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}") - return _validate(ComponentReleaseWithCollection, data) + return _validate(ComponentReleaseWithCollection, data, legacy=self.is_legacy) def get_component_release_collection_latest(self, uuid: str) -> Collection: """Get the latest collection for a component release. @@ -455,7 +492,7 @@ def get_component_release_collection_latest(self, uuid: str) -> Collection: The latest collection with its artifacts. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collection/latest") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def get_component_release_collections(self, uuid: str) -> list[Collection]: """Get all collection versions for a component release. @@ -467,7 +504,7 @@ def get_component_release_collections(self, uuid: str) -> list[Collection]: List of all collection versions. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data) + return _validate_list(Collection, data, legacy=self.is_legacy) def get_component_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a component release. @@ -481,7 +518,7 @@ def get_component_release_collection(self, uuid: str, version: int) -> Collectio """ _validate_collection_version(version) data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collection/{version}") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def list_component_releases( self, *, page_offset: int = 0, page_size: int = 100 @@ -501,7 +538,7 @@ def list_component_releases( "/componentReleases", params={"pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedComponentReleaseResponse, data) + return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) def search_component_releases( self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 @@ -523,7 +560,7 @@ def search_component_releases( "/componentReleases", params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, ) - return _validate(PaginatedComponentReleaseResponse, data) + return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) # --- CLE --- @@ -537,7 +574,7 @@ def get_product_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/product/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_product_release_cle(self, uuid: str) -> CLE: """Get CLE data for a product release. @@ -549,7 +586,7 @@ def get_product_release_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_component_cle(self, uuid: str) -> CLE: """Get CLE data for a component. @@ -561,7 +598,7 @@ def get_component_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_component_release_cle(self, uuid: str) -> CLE: """Get CLE data for a component release. @@ -573,7 +610,7 @@ def get_component_release_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) # --- Artifacts --- @@ -587,7 +624,7 @@ def get_artifact(self, uuid: str) -> Artifact: The artifact with its formats and download URLs. """ data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/latest") - return _validate(Artifact, data) + return _validate(Artifact, data, legacy=self.is_legacy) def get_artifact_version(self, uuid: str, version: int) -> Artifact: """Get a specific version of artifact metadata by UUID. @@ -601,7 +638,7 @@ def get_artifact_version(self, uuid: str, version: int) -> Artifact: """ _validate_artifact_version(version) data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/{version}") - return _validate(Artifact, data) + return _validate(Artifact, data, legacy=self.is_legacy) def download_artifact( self, diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index 1989310..4b71f5e 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -42,6 +42,9 @@ class TeaClientProtocol(Protocol): :func:`~libtea._http.probe_endpoint` instead. """ + spec_version: str + is_legacy: bool + # --- Discovery --- def discover(self, tei: str) -> list[DiscoveryInfo]: ... diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 29dcf53..0e917b6 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -24,7 +24,7 @@ def _all_output(result): return out -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") @@ -513,7 +513,7 @@ def test_token_over_http_shows_clean_error(self): "get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", - "http://example.com/v1", + "http://example.com/v0.4.0", "--token", "t", ], @@ -1514,19 +1514,19 @@ def test_conformance_json_output(self): "totalResults": 0, "results": [], } - responses.get("https://tea.example.com/v1/products", json=paginated_empty) - responses.get("https://tea.example.com/v1/productReleases", json=paginated_empty) + responses.get("https://tea.example.com/v0.4.0/products", json=paginated_empty) + responses.get("https://tea.example.com/v0.4.0/productReleases", json=paginated_empty) responses.get( - "https://tea.example.com/v1/product/00000000-0000-0000-0000-000000000000", + "https://tea.example.com/v0.4.0/product/00000000-0000-0000-0000-000000000000", status=404, json={"error": "OBJECT_UNKNOWN"}, ) responses.get( - "https://tea.example.com/v1/discovery", + "https://tea.example.com/v0.4.0/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}, ) - result = runner.invoke(app, ["conformance", "--base-url", "https://tea.example.com/v1", "--json"]) + result = runner.invoke(app, ["conformance", "--base-url", "https://tea.example.com/v0.4.0", "--json"]) assert result.exit_code in (0, 1) combined = _all_output(result) assert "base_url" in combined or "checks" in combined diff --git a/tests/client/test_cache.py b/tests/client/test_cache.py index ef9486c..02de7e5 100644 --- a/tests/client/test_cache.py +++ b/tests/client/test_cache.py @@ -8,7 +8,7 @@ from libtea.client import TeaClient -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" UUID1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" UUID2 = "b2c3d4e5-f6a7-8901-bcde-f12345678901" diff --git a/tests/client/test_client.py b/tests/client/test_client.py index a48e283..17f35f2 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -805,93 +805,93 @@ def test_from_well_known_passes_token(self, base_url): class TestProbeEndpoint: @responses.activate def test_probe_success(self): - responses.head("https://api.example.com/v1", status=200) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=200) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_404_triggers_failover(self): """404 means wrong path/version — probe should fail to trigger failover.""" - responses.head("https://api.example.com/v1", status=404) + responses.head("https://api.example.com/v0.4.0", status=404) with pytest.raises(TeaConnectionError, match="HTTP 404"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_410_triggers_failover(self): """410 Gone means endpoint removed — probe should fail to trigger failover.""" - responses.head("https://api.example.com/v1", status=410) + responses.head("https://api.example.com/v0.4.0", status=410) with pytest.raises(TeaConnectionError, match="HTTP 410"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_401_is_ok(self): """401 means auth required but server is reachable — probe should succeed.""" - responses.head("https://api.example.com/v1", status=401) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=401) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_405_is_ok(self): """405 means HEAD not allowed but server is reachable — probe should succeed.""" - responses.head("https://api.example.com/v1", status=405) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=405) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_trailing_slash_redirect_is_ok(self): """Trailing-slash redirect (e.g. Django APPEND_SLASH) should not fail the probe.""" - responses.head("https://api.example.com/v1", status=301, headers={"Location": "/v1/"}) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=301, headers={"Location": "/v0.4.0/"}) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_trailing_slash_redirect_absolute_url(self): """Trailing-slash redirect with absolute Location header.""" responses.head( - "https://api.example.com/v1", + "https://api.example.com/v0.4.0", status=301, - headers={"Location": "https://api.example.com/v1/"}, + headers={"Location": "https://api.example.com/v0.4.0/"}, ) - probe_endpoint("https://api.example.com/v1") # should not raise + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_cross_path_redirect_raises_connection_error(self): """Redirect to a different path should fail.""" - responses.head("https://api.example.com/v1", status=302, headers={"Location": "/elsewhere"}) + responses.head("https://api.example.com/v0.4.0", status=302, headers={"Location": "/elsewhere"}) with pytest.raises(TeaConnectionError, match="redirect"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_cross_origin_redirect_raises_connection_error(self): """Redirect to a different host should fail.""" responses.head( - "https://api.example.com/v1", + "https://api.example.com/v0.4.0", status=301, - headers={"Location": "https://evil.example.com/v1/"}, + headers={"Location": "https://evil.example.com/v0.4.0/"}, ) with pytest.raises(TeaConnectionError, match="redirect"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_500_raises_server_error(self): - responses.head("https://api.example.com/v1", status=500) + responses.head("https://api.example.com/v0.4.0", status=500) with pytest.raises(TeaServerError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_connection_error_raises(self): - responses.head("https://api.example.com/v1", body=requests.ConnectionError("refused")) + responses.head("https://api.example.com/v0.4.0", body=requests.ConnectionError("refused")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_timeout_raises(self): - responses.head("https://api.example.com/v1", body=requests.Timeout("timed out")) + responses.head("https://api.example.com/v0.4.0", body=requests.Timeout("timed out")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_request_exception_raises(self): """Generic RequestException (not ConnectionError/Timeout) also raises TeaConnectionError.""" - responses.head("https://api.example.com/v1", body=requests.exceptions.TooManyRedirects("too many")) + responses.head("https://api.example.com/v0.4.0", body=requests.exceptions.TooManyRedirects("too many")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") class TestVersionNegotiation: diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py new file mode 100644 index 0000000..62045b2 --- /dev/null +++ b/tests/client/test_client_v1.py @@ -0,0 +1,52 @@ +from unittest.mock import patch + +import pytest +import responses + +from libtea._validation import _validate, _validate_list +from libtea.client import TeaClient +from tests.spec_data import LEGACY_BASE_URL, PRODUCT, V1_BASE_URL, product + + +class TestSpecVersion: + def test_inferred_from_base_url(self): + c = TeaClient(LEGACY_BASE_URL) + assert (c.spec_version, c.is_legacy) == ("0.4.0", True) + + def test_build_metadata_kept(self): + assert TeaClient("https://api.example.com/v1.0.0+build.5").spec_version == "1.0.0+build.5" + + def test_defaults_to_1_0_0_without_semver_suffix(self): + c = TeaClient("https://api.example.com/tea/v1") + assert (c.spec_version, c.is_legacy) == ("1.0.0", False) + + def test_explicit_overrides_url(self): + assert TeaClient(V1_BASE_URL, spec_version="0.4.0").is_legacy is True + + @pytest.mark.parametrize("v", ["2.0.0", "not-semver"]) + def test_rejects_unsupported(self, v): + with pytest.raises(ValueError, match="Unsupported TEA spec version"): + TeaClient("https://api.example.com", spec_version=v) + + +CASES = [("legacy", LEGACY_BASE_URL, True), ("v1", V1_BASE_URL, False)] + + +class TestLegacyFlagReachesValidation: + @responses.activate + @pytest.mark.parametrize("fixture, base_url, expected", CASES) + def test_validate(self, request, fixture, base_url, expected): + client = request.getfixturevalue(fixture) + responses.get(f"{base_url}/product/{PRODUCT}", json=product(PRODUCT, "Widget")) + with patch("libtea.client._validate", wraps=_validate) as spy: + client.get_product(PRODUCT) + assert spy.call_args.kwargs == {"legacy": expected} + + @responses.activate + @pytest.mark.parametrize("fixture, base_url, expected", CASES) + def test_validate_list(self, request, fixture, base_url, expected): + client = request.getfixturevalue(fixture) + responses.get(f"{base_url}/discovery", json=[]) + with patch("libtea.client._validate_list", wraps=_validate_list) as spy: + client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") + assert spy.call_args.kwargs == {"legacy": expected} diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 1d63ef8..7347e7a 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -314,7 +314,7 @@ def test_404_with_json_array_body(self, http_client, base_url): assert exc_info.value.error_type is None -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" class TestBasicAuth: @@ -492,10 +492,10 @@ class TestResponseSizeLimit: @responses.activate def test_rejects_oversized_content_length(self): """Content-Length header advertising oversized body triggers rejection.""" - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") client._max_response_bytes = 5 # Very small limit responses.get( - "https://api.example.com/v1/product/abc", + "https://api.example.com/v0.4.0/product/abc", json={"uuid": "abc"}, status=200, ) @@ -506,10 +506,10 @@ def test_rejects_oversized_content_length(self): @responses.activate def test_rejects_oversized_body(self): """Body exceeding limit is rejected even without Content-Length.""" - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") client._max_response_bytes = 100 large_body = b'{"data": "' + b"x" * 200 + b'"}' - responses.get("https://api.example.com/v1/product/abc", body=large_body, status=200) + responses.get("https://api.example.com/v0.4.0/product/abc", body=large_body, status=200) with pytest.raises(TeaValidationError, match="exceeds limit"): client.get_json("/product/abc") client.close() @@ -517,8 +517,8 @@ def test_rejects_oversized_body(self): @responses.activate def test_normal_response_passes(self): """Normal-sized responses pass the size check.""" - client = TeaHttpClient("https://api.example.com/v1") - responses.get("https://api.example.com/v1/product/abc", json={"uuid": "abc"}, status=200) + client = TeaHttpClient("https://api.example.com/v0.4.0") + responses.get("https://api.example.com/v0.4.0/product/abc", json={"uuid": "abc"}, status=200) result = client.get_json("/product/abc") assert result == {"uuid": "abc"} client.close() @@ -536,12 +536,12 @@ def test_gzip_response_decoded(self): payload = json.dumps({"uuid": "abc", "name": "Gzipped"}).encode() compressed = gzip.compress(payload) responses.get( - "https://api.example.com/v1/product/abc", + "https://api.example.com/v0.4.0/product/abc", body=compressed, headers={"Content-Encoding": "gzip", "Content-Type": "application/json"}, status=200, ) - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") result = client.get_json("/product/abc") assert result["name"] == "Gzipped" client.close() diff --git a/tests/client/test_iterators.py b/tests/client/test_iterators.py index a3897c1..8f4bbc8 100644 --- a/tests/client/test_iterators.py +++ b/tests/client/test_iterators.py @@ -6,7 +6,7 @@ from libtea.client import TeaClient from libtea.models import Component, Product, ProductRelease, Release -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" def _product_json(uuid: str, name: str) -> dict: diff --git a/tests/conftest.py b/tests/conftest.py index 7518e12..acb13b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,9 @@ from libtea._http import TeaHttpClient from libtea.client import TeaClient +from tests.spec_data import LEGACY_BASE_URL, V1_BASE_URL -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" # Public IP used by getaddrinfo mock so SSRF validation passes for test hostnames. _FAKE_PUBLIC_ADDR = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] @@ -38,6 +39,20 @@ def client(): c.close() +@pytest.fixture +def v1(): + c = TeaClient(V1_BASE_URL) + yield c + c.close() + + +@pytest.fixture +def legacy(): + c = TeaClient(LEGACY_BASE_URL) + yield c + c.close() + + @pytest.fixture def http_client(): c = TeaHttpClient(base_url=BASE_URL) diff --git a/tests/spec_data.py b/tests/spec_data.py new file mode 100644 index 0000000..e59aa5c --- /dev/null +++ b/tests/spec_data.py @@ -0,0 +1,49 @@ +LEGACY_BASE_URL = "https://api.example.com/tea/v0.4.0" +V1_BASE_URL = "https://api.example.com/tea/v1.0.0" +LEGACY = {"tea_legacy": True} + +PRODUCT = "09e8c73b-ac45-4475-acac-33e6a7314e6d" +PRODUCT_RELEASE = "123e4567-e89b-12d3-a456-426614174000" +COMPONENT = "b844c9bd-55d6-478c-af59-954a932b6ad3" +COMPONENT_RELEASE = "da89e38e-95e7-44ca-aa7d-f3b6b34c7fab" +ARTIFACT = "1cb47b95-8bf8-3bad-a5a4-0d54d86e10ce" +SHA256_HEX = "e04c9d55986d7194822eaa4f8115a77f801844d807ad6e0d454ac31dd41861e5" + +V1_ARTIFACT = { + "uuid": ARTIFACT, + "version": 2, + "type": "BOM", + "createdDate": "2024-12-13T00:00:00Z", + "formats": [ + { + "mediaType": "application/vnd.cyclonedx+xml", + "url": "https://repo.example.com/sbom.xml", + "checksums": [{"algType": "SHA-256", "algValue": SHA256_HEX}], + } + ], +} +V1_COLLECTION = { + "uuid": COMPONENT_RELEASE, + "version": 2, + "createdDate": "2025-05-12T18:08:00Z", + "belongsTo": "COMPONENT_RELEASE", + "updateReason": {"type": "VEX_UPDATED"}, + "artifacts": [V1_ARTIFACT], +} +V1_RELEASE = { + "uuid": COMPONENT_RELEASE, + "version": "2.24.3", + "createdDate": "2024-12-10T10:51:00Z", + "component": COMPONENT, +} +V1_PRODUCT_RELEASE = { + "uuid": PRODUCT_RELEASE, + "version": "2.24.3", + "createdDate": "2025-04-01T15:43:00Z", + "product": PRODUCT, + "components": [{"uuid": COMPONENT}], +} + + +def product(uuid: str, name: str) -> dict: + return {"uuid": uuid, "name": name, "identifiers": []} diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 892c4f1..0aa1718 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -35,7 +35,7 @@ ) from libtea.conformance._types import CheckStatus -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" # Reusable response payloads --------------------------------------------------- diff --git a/tests/unit/test_conformance_plugin.py b/tests/unit/test_conformance_plugin.py index 192c442..087bb8a 100644 --- a/tests/unit/test_conformance_plugin.py +++ b/tests/unit/test_conformance_plugin.py @@ -144,8 +144,8 @@ def test_client_created_with_correct_params(self) -> None: # The fixture extracts base_url, token, and timeout from config. # We test the same logic directly. - client = TeaClient(base_url="https://tea.example.com/v1", token="secret", timeout=15.0) - assert client._http._base_url == "https://tea.example.com/v1" + client = TeaClient(base_url="https://tea.example.com/v0.4.0", token="secret", timeout=15.0) + assert client._http._base_url == "https://tea.example.com/v0.4.0" client.close() diff --git a/tests/unit/test_conformance_runner.py b/tests/unit/test_conformance_runner.py index 89db020..ecca43c 100644 --- a/tests/unit/test_conformance_runner.py +++ b/tests/unit/test_conformance_runner.py @@ -2,7 +2,7 @@ from libtea.conformance import ConformanceResult, run_conformance -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" _PAGINATED_EMPTY = { "timestamp": "2024-01-01T00:00:00Z", diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py index 72265e2..33a2782 100644 --- a/tests/unit/test_protocols.py +++ b/tests/unit/test_protocols.py @@ -3,7 +3,7 @@ from libtea.client import TeaClient from libtea.protocols import TeaClientProtocol -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" class TestProtocol: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 19a2d36..7db9c8a 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,9 +1,14 @@ +from typing import Any + import pytest +from pydantic import BaseModel, ValidationInfo, field_validator from libtea._validation import ( _MAX_PAGE_SIZE, + _validate, _validate_artifact_version, _validate_collection_version, + _validate_list, _validate_page_offset, _validate_page_size, _validate_path_segment, @@ -11,6 +16,33 @@ from libtea.exceptions import TeaValidationError +class _EchoContext(BaseModel): + """Stores the validation context it was validated with in ``n``.""" + + n: Any = None + + @field_validator("n", mode="before") + @classmethod + def _echo(cls, _value: Any, info: ValidationInfo) -> Any: + return info.context + + +class TestValidationContext: + def test_validate_passes_no_context_by_default(self): + assert _validate(_EchoContext, {"n": 1}).n is None + + def test_validate_passes_legacy_context(self): + assert _validate(_EchoContext, {"n": 1}, legacy=True).n == {"tea_legacy": True} + + def test_validate_list_passes_no_context_by_default(self): + assert [m.n for m in _validate_list(_EchoContext, [{"n": 1}, {"n": 2}])] == [None, None] + + def test_validate_list_passes_legacy_context(self): + assert [m.n for m in _validate_list(_EchoContext, [{"n": 1}, {"n": 2}], legacy=True)] == [ + {"tea_legacy": True} + ] * 2 + + class TestValidatePathSegment: def test_accepts_uuid(self): assert _validate_path_segment("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" From ec17a93534ed4902710a77d29b74af680d9ece50 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 18:56:54 +0500 Subject: [PATCH 03/29] feat!: enforce TEA 1.0.0 required fields, keep 0.4.0 via legacy context --- src/libtea/_cli_fmt.py | 30 ++-- src/libtea/client.py | 13 +- src/libtea/conformance/_checks.py | 16 +-- src/libtea/models.py | 119 +++++++++++----- tests/cli/test_cli.py | 101 ++++++++++---- tests/cli/test_cli_fmt.py | 188 ++++++++++++-------------- tests/client/test_client.py | 51 ++----- tests/client/test_client_v1.py | 13 +- tests/integration/test_integration.py | 45 +----- tests/unit/test_conformance_checks.py | 86 +----------- tests/unit/test_models.py | 160 ++++++++++------------ 11 files changed, 363 insertions(+), 459 deletions(-) diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index 34fe077..795c9e3 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -93,7 +93,7 @@ def _distributions_table(distributions: Sequence[ReleaseDistribution], *, consol if not distributions: return tbl = Table(title="Distributions") - tbl.add_column("ID / Type") + tbl.add_column("ID") tbl.add_column("Description") tbl.add_column("URL") tbl.add_column("Signature URL") @@ -102,9 +102,7 @@ def _distributions_table(distributions: Sequence[ReleaseDistribution], *, consol checksums = ( ", ".join(f"{_opt(cs.algorithm_type)}:{(_opt(cs.algorithm_value))[:12]}..." for cs in d.checksums) or "-" ) - # Prefer v0.4.0 distribution_id, fall back to legacy distribution_type - id_or_type = d.distribution_id or d.distribution_type - tbl.add_row(_esc(id_or_type), _esc(d.description), _esc(d.url), _esc(d.signature_url), escape(checksums)) + tbl.add_row(_esc(d.distribution_id), _esc(d.description), _esc(d.url), _esc(d.signature_url), escape(checksums)) console.print(tbl) @@ -120,11 +118,7 @@ def _artifacts_table(artifacts: Sequence[Artifact], *, console: Console) -> None tbl.add_column("Formats") for a in artifacts: fmt_str = ", ".join(f.media_type or "?" for f in a.formats) or "-" - # Prefer v0.4.0 distribution_ids, fall back to legacy distribution_types. - # Use `is not None` so an explicitly empty list from a v0.4.0 server - # does not fall back to legacy data (empty tuple is falsy). - applies_seq = a.distribution_ids if a.distribution_ids is not None else (a.distribution_types or ()) - applies = ", ".join(applies_seq) or "-" + applies = ", ".join(a.distribution_ids or ()) or "-" tbl.add_row(_esc(a.uuid), _esc(a.name), _esc(a.type), escape(applies), escape(fmt_str)) console.print(tbl) @@ -275,7 +269,7 @@ def fmt_component_release(data: ComponentReleaseWithCollection, *, console: Cons [ ("UUID", _opt(col.uuid)), ("Version", _opt(col.version)), - ("Date", _opt(col.date)), + ("Date", _opt(col.created_date)), ("Belongs To", _opt(col.belongs_to)), ], console=console, @@ -295,7 +289,7 @@ def fmt_collection(data: Collection, *, console: Console) -> None: [ ("UUID", _opt(data.uuid)), ("Version", _opt(data.version)), - ("Date", _opt(data.date)), + ("Date", _opt(data.created_date)), ("Belongs To", _opt(data.belongs_to)), ("Update Reason", reason), ], @@ -363,7 +357,7 @@ def fmt_collections(data: list[Collection], *, console: Console) -> None: tbl.add_row( _esc(col.uuid), _esc(col.version), - _esc(col.date), + _esc(col.created_date), _esc(col.belongs_to), str(len(col.artifacts)), ) @@ -479,7 +473,7 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non if distributions: comp_name = comp.get("name") or release.get("componentName", "Component") tbl = Table(title=f"Distributions ({_esc(comp_name)})") - tbl.add_column("ID / Type") + tbl.add_column("ID") tbl.add_column("Description") tbl.add_column("URL") tbl.add_column("Signature URL") @@ -489,10 +483,8 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non checksums = ( ", ".join(f"{cs.get('algType', '?')}:{cs.get('algValue', '')[:12]}..." for cs in checksums_list) or "-" ) - # Prefer v0.4.0 distributionId, fall back to legacy distributionType - id_or_type = d.get("distributionId") or d.get("distributionType") tbl.add_row( - _esc(id_or_type), + _esc(d.get("distributionId")), _esc(d.get("description")), _esc(d.get("url")), _esc(d.get("signatureUrl")), @@ -518,11 +510,7 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non tbl.add_column("URL") tbl.add_column("Signature URL") for art in artifacts: - # Prefer v0.4.0 distributionIds, fall back to legacy distributionTypes. - # Use explicit key check so an empty v0.4.0 list does not fall back. - dist_ids = art.get("distributionIds") - applies_seq = dist_ids if dist_ids is not None else (art.get("distributionTypes") or []) - applies = ", ".join(applies_seq) or "-" + applies = ", ".join(art.get("distributionIds") or []) or "-" formats = art.get("formats", []) if formats: for fmt in formats: diff --git a/src/libtea/client.py b/src/libtea/client.py index 6e640d9..d3610fc 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -668,19 +668,14 @@ def download_artifact( TeaConnectionError: On network failure. TeaValidationError: If download exceeds max_download_bytes. """ - if verify_checksums: - # Skip checksums with missing algorithm_type or algorithm_value (optional per spec) - verify_checksums = [cs for cs in verify_checksums if cs.algorithm_type and cs.algorithm_value] - alg_names = {cs.algorithm_type.value for cs in verify_checksums if cs.algorithm_type} - weak = alg_names & WEAK_HASH_ALGORITHMS + algorithms = [cs.algorithm_type.value for cs in verify_checksums] if verify_checksums else None + if algorithms: + weak = set(algorithms) & WEAK_HASH_ALGORITHMS if weak: warnings.warn( f"Verifying with weak hash algorithm(s): {', '.join(sorted(weak))}. Prefer SHA-256 or stronger.", stacklevel=2, ) - algorithms = ( - [cs.algorithm_type.value for cs in verify_checksums if cs.algorithm_type] if verify_checksums else None - ) computed = self._http.download_with_hashes( url, dest, @@ -705,8 +700,6 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: TeaChecksumError: If any checksum does not match. """ for cs in checksums: - if cs.algorithm_type is None or cs.algorithm_value is None: - continue # skip incomplete checksums (fields optional per spec) alg_name = cs.algorithm_type.value expected = cs.algorithm_value.lower() if alg_name not in computed: diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index 4ef1926..1c08b03 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -261,11 +261,9 @@ def check_product_release_collection_latest(client: TeaClient, ctx: CheckContext except TeaError as exc: return _fail(name, f"get_product_release_collection_latest() failed: {exc}", details=str(exc)) if collection.artifacts: - for artifact in collection.artifacts: - if artifact.uuid: - ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid - _collect(ctx, artifact.uuid) - break + artifact = collection.artifacts[0] + ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid + _collect(ctx, artifact.uuid) return _pass(name, f"Got collection v{collection.version} with {len(collection.artifacts)} artifact(s)") @@ -341,11 +339,9 @@ def check_component_release(client: TeaClient, ctx: CheckContext) -> CheckResult return _fail(name, f"get_component_release() failed: {exc}", details=str(exc)) _collect(ctx, result.release.uuid) if result.latest_collection and result.latest_collection.artifacts: - for artifact in result.latest_collection.artifacts: - if artifact.uuid: - ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid - _collect(ctx, artifact.uuid) - break + artifact = result.latest_collection.artifacts[0] + ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid + _collect(ctx, artifact.uuid) return _pass(name, f"Got release v{result.release.version}") diff --git a/src/libtea/models.py b/src/libtea/models.py index d9e17b1..87a2d1f 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -3,16 +3,25 @@ All models use camelCase aliases for JSON serialization (matching the TEA wire format), are frozen (immutable after creation), and silently ignore unknown fields for forward-compatibility with future TEA spec versions. + +Fields required by both TEA 0.4.0 and 1.0.0 are non-optional types. Fields +required only by 1.0.0 are optional types that are rejected when missing, +unless the model is validated with ``context={"tea_legacy": True}``. """ from datetime import datetime from enum import StrEnum -from typing import Literal, Self +from typing import Any, ClassVar, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from pydantic.alias_generators import to_camel +def _is_legacy(info: ValidationInfo) -> bool: + """Return ``True`` when validating in the TEA 0.4.0 compatibility context.""" + return bool(info.context and info.context.get("tea_legacy")) + + class _TeaModel(BaseModel): """Base model for all TEA API objects. @@ -21,6 +30,11 @@ class _TeaModel(BaseModel): - ``populate_by_name=True``: Fields can be set by Python name or alias. - ``extra="ignore"``: Unknown fields from newer spec versions are silently dropped. - ``frozen=True``: Instances are immutable (hashable, safe to cache). + + TEA 1.0.0 rules: fields named in ``_v1_required`` must not be ``None``. + Subclasses add other 1.0.0-only checks by overriding :meth:`_v1_problems` + and extending ``super()._v1_problems()``. The checks are skipped under the + legacy validation context. """ model_config = ConfigDict( @@ -30,6 +44,17 @@ class _TeaModel(BaseModel): frozen=True, ) + _v1_required: ClassVar[tuple[str, ...]] = () + + def _v1_problems(self) -> list[str]: + return [f"missing required field {n!r}" for n in self._v1_required if getattr(self, n) is None] + + @model_validator(mode="after") + def _enforce_v1(self, info: ValidationInfo) -> Self: + if not _is_legacy(info) and (problems := self._v1_problems()): + raise ValueError(f"{type(self).__name__}: {'; '.join(problems)} (TEA 1.0.0)") + return self + # --- Enums --- @@ -60,7 +85,7 @@ class ComplianceDocumentType(StrEnum): ISO_42001 = "ISO_42001" PCI_DSS = "PCI_DSS" HIPAA = "HIPAA" - FEDRAMP = "FedRAMP" + FEDRAMP = "FEDRAMP" GDPR = "GDPR" CSA_STAR = "CSA_STAR" NIST_800_53 = "NIST_800_53" @@ -70,6 +95,7 @@ class ComplianceDocumentType(StrEnum): TISAX = "TISAX" CYBER_ESSENTIALS = "CYBER_ESSENTIALS" CYBER_ESSENTIALS_PLUS = "CYBER_ESSENTIALS_PLUS" + EU_DECLARATION_OF_CONFORMITY = "EU_DECLARATION_OF_CONFORMITY" class TeiType(StrEnum): @@ -183,9 +209,11 @@ class Identifier(_TeaModel): future TEA spec versions. Compare against :class:`IdentifierType` members for known types (e.g. ``ident.id_type == IdentifierType.PURL``). - Per spec, neither field is required. + Both fields are required by TEA 1.0.0; 0.4.0 servers may omit either. """ + _v1_required = ("id_type", "id_value") + id_type: str | None = None id_value: str | None = None @@ -200,8 +228,8 @@ class Checksum(_TeaModel): # Manual aliases because the TEA spec wire format uses abbreviated names # (algType/algValue) rather than the full algorithmType/algorithmValue # that to_camel would generate from the Python field names. - algorithm_type: ChecksumAlgorithm | None = Field(default=None, alias="algType") - algorithm_value: str | None = Field(default=None, alias="algValue") + algorithm_type: ChecksumAlgorithm = Field(alias="algType") + algorithm_value: str = Field(alias="algValue") @field_validator("algorithm_type", mode="before") @classmethod @@ -222,16 +250,9 @@ def normalize_algorithm_type(cls, v: str) -> str: class ReleaseDistribution(_TeaModel): - """A distribution format for a component release (e.g. binary, source). - - As of spec v0.4.0, ``distribution_id`` is required and ``distribution_type`` - has been removed. Both fields are kept as **optional** in the model for - backward compatibility with older servers that still send - ``distributionType`` instead. - """ + """A distribution format for a component release (e.g. binary, source).""" - distribution_id: str | None = None - distribution_type: str | None = None + distribution_id: str description: str | None = None identifiers: tuple[Identifier, ...] = () url: str | None = None @@ -240,7 +261,12 @@ class ReleaseDistribution(_TeaModel): class ArtifactFormat(_TeaModel): - """A TEA artifact in a specific format with download URL and checksums.""" + """A TEA artifact in a specific format with download URL and checksums. + + TEA 1.0.0 requires ``media_type``, and at least one checksum when ``url`` is set. + """ + + _v1_required = ("media_type",) media_type: str | None = None description: str | None = None @@ -248,40 +274,44 @@ class ArtifactFormat(_TeaModel): signature_url: str | None = None checksums: tuple[Checksum, ...] = () - @model_validator(mode="before") - @classmethod - def _normalize_mime_type(cls, data: dict) -> dict: # type: ignore[type-arg] - """Accept ``mimeType`` as an alias for ``mediaType`` (older server compat).""" - if isinstance(data, dict) and "mimeType" in data and "mediaType" not in data: - data["mediaType"] = data.pop("mimeType") - return data + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if self.url is not None and not self.checksums: + problems.append("'checksums' must not be empty when 'url' is set") + return problems class Artifact(_TeaModel): """A security-related artifact (e.g. SBOM, VEX, attestation) with available formats. - As of spec v0.4.0, ``uuid`` is required and ``distribution_ids`` replaces - ``distribution_types``. All fields are kept as **optional** in the model - for backward compatibility with older servers that may omit ``uuid`` or - still send ``distributionTypes``. + TEA 1.0.0 also requires ``version``, ``created_date`` and at least one format. """ - uuid: str | None = None + _v1_required = ("version", "created_date") + + uuid: str name: str | None = None - type: ArtifactType | None = None + type: ArtifactType version: int | None = Field(default=None, ge=1) created_date: datetime | None = None distribution_ids: tuple[str, ...] | None = None - distribution_types: tuple[str, ...] | None = None - formats: tuple[ArtifactFormat, ...] = () + formats: tuple[ArtifactFormat, ...] + + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if not self.formats: + problems.append("'formats' must not be empty") + return problems class CollectionUpdateReason(_TeaModel): """Reason for a collection version update, with optional comment. - Per spec, no fields are required. + TEA 1.0.0 requires ``type``. """ + _v1_required = ("type",) + type: CollectionUpdateReasonType | None = None comment: str | None = None @@ -291,24 +321,35 @@ class Collection(_TeaModel): The UUID matches the owning component or product release. The version integer starts at 1 and increments on each content change. - Per spec, all fields are optional. + TEA 1.0.0 requires ``uuid``, ``version``, ``created_date``, ``belongs_to`` and + ``update_reason``; 0.4.0 requires none and names the creation timestamp ``date``. Attributes: uuid: UUID of the owning component or product release. version: Collection version number (starts at 1, increments on change). - date: Timestamp when this collection version was created. + created_date: Timestamp when this collection version was created. belongs_to: Whether this collection belongs to a component or product release. update_reason: Why this collection version was created. artifacts: The artifacts (SBOMs, VEX documents, etc.) in this collection. """ + _v1_required = ("uuid", "version", "created_date", "belongs_to", "update_reason") + uuid: str | None = None version: int | None = Field(default=None, ge=1) - date: datetime | None = None + created_date: datetime | None = None belongs_to: CollectionBelongsTo | None = None update_reason: CollectionUpdateReason | None = None artifacts: tuple[Artifact, ...] = () + @model_validator(mode="before") + @classmethod + def _map_legacy_date(cls, data: Any, info: ValidationInfo) -> Any: + """Map the 0.4.0 ``date`` key to ``createdDate`` (legacy context only).""" + if _is_legacy(info) and isinstance(data, dict) and "date" in data: + return {"createdDate": data["date"], **data} + return data + class ComponentRef(_TeaModel): """Reference to a TEA component, optionally pinned to a specific release.""" @@ -330,7 +371,7 @@ class Release(_TeaModel): Attributes: uuid: Server-assigned UUID. - component: UUID of the parent component (set when returned in context). + component: UUID of the parent component (required by TEA 1.0.0). component_name: Human-readable name of the parent component. version: Version string (e.g. ``"1.2.3"``). created_date: When the release record was created on the TEA server. @@ -340,6 +381,8 @@ class Release(_TeaModel): distributions: Available distribution formats (binary, source, etc.). """ + _v1_required = ("component",) + uuid: str component: str | None = None component_name: str | None = None @@ -377,7 +420,7 @@ class ProductRelease(_TeaModel): Attributes: uuid: Server-assigned UUID. - product: UUID of the parent product (set when returned in context). + product: UUID of the parent product (required by TEA 1.0.0). product_name: Human-readable name of the parent product. version: Version string (e.g. ``"2.0.0"``). created_date: When the release record was created on the TEA server. @@ -387,6 +430,8 @@ class ProductRelease(_TeaModel): components: References to the components included in this product release. """ + _v1_required = ("product",) + uuid: str product: str | None = None product_name: str | None = None diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 0e917b6..e74a468 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -12,6 +12,7 @@ from click.testing import CliRunner # noqa: E402 from libtea.cli import app # noqa: E402 +from tests.spec_data import LEGACY # noqa: E402 runner = CliRunner() @@ -1610,10 +1611,14 @@ def test_tei_mode_artifact_download_failure_warns(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "BadArt", "formats": [{"url": bad_url, "checksums": []}], }, { + "uuid": "art-uuid-2", + "type": "BOM", "name": "GoodArt", "formats": [{"url": good_url, "checksums": []}], }, @@ -1648,7 +1653,14 @@ def test_tei_mode_all_downloads_fail(self, tmp_path): json={ "uuid": pr_uuid, "version": 1, - "artifacts": [{"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}], + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + } + ], }, ) responses.get(artifact_url, status=500) @@ -1725,6 +1737,8 @@ def test_tei_mode_skips_formats_without_url(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ {"mediaType": "application/xml", "checksums": []}, @@ -1769,6 +1783,8 @@ def test_tei_mode_checksum_verification(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ { @@ -1803,6 +1819,8 @@ def test_tei_mode_checksum_failure_shown_without_error_prefix(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ { @@ -1857,11 +1875,23 @@ def test_tei_mode_multiple_discoveries(self, tmp_path): ) responses.get( f"{BASE_URL}/productRelease/{pr1}/collection/latest", - json={"uuid": pr1, "version": 1, "artifacts": [{"name": "A", "formats": [{"url": url1, "checksums": []}]}]}, + json={ + "uuid": pr1, + "version": 1, + "artifacts": [ + {"uuid": "art-uuid-1", "type": "BOM", "name": "A", "formats": [{"url": url1, "checksums": []}]} + ], + }, ) responses.get( f"{BASE_URL}/productRelease/{pr2}/collection/latest", - json={"uuid": pr2, "version": 1, "artifacts": [{"name": "B", "formats": [{"url": url2, "checksums": []}]}]}, + json={ + "uuid": pr2, + "version": 1, + "artifacts": [ + {"uuid": "art-uuid-2", "type": "BOM", "name": "B", "formats": [{"url": url2, "checksums": []}]} + ], + }, ) responses.get(url1, body=b"one") responses.get(url2, body=b"two") @@ -1888,8 +1918,8 @@ def test_tei_mode_filename_collision_deduplicates(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "A", "formats": [{"url": url1, "checksums": []}]}, - {"name": "B", "formats": [{"url": url2, "checksums": []}]}, + {"uuid": "art-uuid-1", "type": "BOM", "name": "A", "formats": [{"url": url1, "checksums": []}]}, + {"uuid": "art-uuid-2", "type": "BOM", "name": "B", "formats": [{"url": url2, "checksums": []}]}, ], }, ) @@ -1904,66 +1934,71 @@ def test_tei_mode_filename_collision_deduplicates(self, tmp_path): assert (dest / "sbom-1.json").read_bytes() == b"second" +def _legacy_format(**fields): + from libtea.models import ArtifactFormat + + return ArtifactFormat.model_validate(fields, context=LEGACY) + + +def _legacy_artifact(**fields): + from libtea.models import Artifact + + return Artifact.model_validate({"uuid": "art-uuid-1", "type": "BOM", "formats": [], **fields}, context=LEGACY) + + class TestArtifactFilename: """Tests for _artifact_filename derivation logic.""" def test_filename_from_url(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(url="https://cdn.example.com/path/to/sbom.cdx.json") - art = Artifact(name="SBOM") + fmt = _legacy_format(url="https://cdn.example.com/path/to/sbom.cdx.json") + art = _legacy_artifact(name="SBOM") assert _artifact_filename(fmt, art, 0) == "sbom.cdx.json" def test_filename_fallback_to_artifact_name(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="my-sbom") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="my-sbom") assert _artifact_filename(fmt, art, 0) == "my-sbom.json" def test_filename_fallback_no_name(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/vnd.cyclonedx+json") - art = Artifact() + fmt = _legacy_format(media_type="application/vnd.cyclonedx+json") + art = _legacy_artifact() assert _artifact_filename(fmt, art, 3) == "artifact-3.cdx.json" def test_filename_url_without_extension_falls_back(self): """URLs ending in generic segments like /download use artifact name instead.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(url="https://cdn.example.com/artifacts/uuid/download", media_type="application/json") - art = Artifact(name="my-sbom") + fmt = _legacy_format(url="https://cdn.example.com/artifacts/uuid/download", media_type="application/json") + art = _legacy_artifact(name="my-sbom") assert _artifact_filename(fmt, art, 0) == "my-sbom.json" def test_filename_unknown_media_type(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/octet-stream") - art = Artifact(name="blob") + fmt = _legacy_format(media_type="application/octet-stream") + art = _legacy_artifact(name="blob") assert _artifact_filename(fmt, art, 0) == "blob" def test_filename_path_traversal_sanitized(self): """Malicious artifact names with path traversal are sanitized.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="../../etc/passwd") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="../../etc/passwd") assert _artifact_filename(fmt, art, 0) == "passwd.json" def test_filename_absolute_path_sanitized(self): """Absolute path in artifact name is sanitized to basename.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="/etc/cron.d/malicious") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="/etc/cron.d/malicious") assert _artifact_filename(fmt, art, 0) == "malicious.json" def test_ext_from_media_type(self): @@ -2112,7 +2147,12 @@ def test_dry_run_shows_would_download(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}, + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, ], }, ) @@ -2149,7 +2189,12 @@ def test_quiet_suppresses_download_progress(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}, + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, ], }, ) diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py index f561a35..0b6beef 100644 --- a/tests/cli/test_cli_fmt.py +++ b/tests/cli/test_cli_fmt.py @@ -57,6 +57,12 @@ ReleaseDistribution, TeaServerInfo, ) +from tests.spec_data import LEGACY # noqa: E402 + + +def _legacy(model_cls, **fields): + """Build a model from 0.4.0-shaped data, skipping the TEA 1.0.0 required-field checks.""" + return model_cls.model_validate(fields, context=LEGACY) def _capture(fn, *args, **kwargs) -> str: @@ -149,14 +155,20 @@ def test_empty_results(self): class TestFmtSearchReleases: def test_renders_table(self): - data = PaginatedProductReleaseResponse( + data = _legacy( + PaginatedProductReleaseResponse, timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, total_results=1, results=[ - ProductRelease( - uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[], pre_release=True + _legacy( + ProductRelease, + uuid=UUID, + version="1.0.0", + created_date="2024-01-01T00:00:00Z", + components=[], + pre_release=True, ) ], ) @@ -190,7 +202,8 @@ def test_markup_escape(self): class TestFmtProductRelease: def test_renders_panel_and_components(self): - data = ProductRelease( + data = _legacy( + ProductRelease, uuid=UUID, version="2.0.0", product_name="My Product", @@ -208,7 +221,7 @@ def test_renders_panel_and_components(self): assert UUID2 in output def test_no_components(self): - data = ProductRelease(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[]) + data = _legacy(ProductRelease, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[]) output = _capture(fmt_product_release, data) assert "Product Release" in output assert "Components" not in output @@ -216,15 +229,17 @@ def test_no_components(self): class TestFmtComponentRelease: def test_renders_release_and_collection(self): - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="1.0.0", component_name="libfoo", created_date="2024-01-01T00:00:00Z", identifiers=[], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Component Release" in output @@ -232,12 +247,14 @@ def test_renders_release_and_collection(self): assert "Latest Collection" in output def test_renders_artifacts(self): - data = ComponentReleaseWithCollection( - release=Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), - latest_collection=Collection( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + latest_collection=_legacy( + Collection, uuid=UUID, version=1, - artifacts=[Artifact(uuid=UUID2, name="SBOM", type="BOM", formats=[])], + artifacts=[_legacy(Artifact, uuid=UUID2, name="SBOM", type="BOM", formats=[])], ), ) output = _capture(fmt_component_release, data) @@ -247,7 +264,8 @@ def test_renders_artifacts(self): class TestFmtCollection: def test_renders_panel(self): - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=3, belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, @@ -263,14 +281,15 @@ def test_renders_panel(self): assert "CVE-2024-1234 fixed" in output def test_no_update_reason(self): - data = Collection(uuid=UUID, version=1, artifacts=[]) + data = _legacy(Collection, uuid=UUID, version=1, artifacts=[]) output = _capture(fmt_collection, data) assert "Collection" in output class TestFmtArtifact: def test_renders_panel_and_formats(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="SBOM", type="BOM", @@ -291,7 +310,7 @@ def test_renders_panel_and_formats(self): assert "abcdef123456" in output def test_no_formats(self): - data = Artifact(uuid=UUID, name="VEX", type="VULNERABILITIES", formats=[]) + data = _legacy(Artifact, uuid=UUID, name="VEX", type="VULNERABILITIES", formats=[]) output = _capture(fmt_artifact, data) assert "Artifact" in output assert "Formats" not in output @@ -528,15 +547,17 @@ class TestDistributionsTable: """Test display of release-distribution fields.""" def test_component_release_with_distributions(self): - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="11.0.7", component_name="tomcat", created_date="2024-01-01T00:00:00Z", distributions=[ ReleaseDistribution( - distribution_type="zip", + distribution_id="zip", description="Core binary distribution, zip archive", url="https://repo.example.com/tomcat-11.0.7.zip", signature_url="https://repo.example.com/tomcat-11.0.7.zip.asc", @@ -545,13 +566,13 @@ def test_component_release_with_distributions(self): ], ), ReleaseDistribution( - distribution_type="tar.gz", + distribution_id="tar.gz", description="Core binary distribution, tar.gz archive", url="https://repo.example.com/tomcat-11.0.7.tar.gz", ), ], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Distributions" in output @@ -563,9 +584,10 @@ def test_component_release_with_distributions(self): assert "SHA-256:abcdef123456" in output def test_component_release_without_distributions(self): - data = ComponentReleaseWithCollection( - release=Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Distributions" not in output @@ -584,7 +606,7 @@ def test_inspect_component_with_distributions(self): "componentName": "tomcat", "distributions": [ { - "distributionType": "zip", + "distributionId": "zip", "description": "Zip archive", "url": "https://repo.example.com/tomcat.zip", "signatureUrl": "https://repo.example.com/tomcat.zip.asc", @@ -607,10 +629,11 @@ def test_inspect_component_with_distributions(self): class TestArtifactFormatDetails: - """Test display of artifact-format description, signatureUrl, and distributionTypes.""" + """Test display of artifact-format description, signatureUrl, and distributionIds.""" def test_formats_table_shows_description_and_signature(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="Build SBOM", type="BOM", @@ -629,35 +652,20 @@ def test_formats_table_shows_description_and_signature(self): assert "sbom.xml.asc" in output assert "application/vnd.cyclonedx+xml" in output - def test_artifacts_table_shows_distribution_types(self): - data = Collection( - uuid=UUID, - version=1, - artifacts=[ - Artifact( - uuid=UUID, - name="Build SBOM", - type="BOM", - distribution_types=["zip", "tar.gz"], - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], - ) - ], - ) - output = _capture(fmt_collection, data) - assert "zip, tar.gz" in output - def test_artifacts_table_prefers_distribution_ids(self): """v0.4.0: distribution_ids should be displayed when present.""" - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=1, artifacts=[ - Artifact( + _legacy( + Artifact, uuid=UUID, name="Build SBOM", type="BOM", distribution_ids=["dist-1", "dist-2"], - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], + formats=[_legacy(ArtifactFormat, media_type="application/xml", url="https://example.com/sbom.xml")], ) ], ) @@ -666,8 +674,10 @@ def test_artifacts_table_prefers_distribution_ids(self): def test_distributions_table_prefers_distribution_id(self): """v0.4.0: distribution_id should be displayed when present.""" - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", @@ -675,46 +685,11 @@ def test_distributions_table_prefers_distribution_id(self): ReleaseDistribution(distribution_id="dist-abc", description="Primary"), ], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "dist-abc" in output - def test_distributions_table_falls_back_to_type(self): - """Legacy servers: distribution_type shown when distribution_id absent.""" - data = ComponentReleaseWithCollection( - release=Release( - uuid=UUID, - version="1.0.0", - created_date="2024-01-01T00:00:00Z", - distributions=[ - ReleaseDistribution(distribution_type="tar.gz", description="Legacy"), - ], - ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), - ) - output = _capture(fmt_component_release, data) - assert "tar.gz" in output - - def test_artifacts_table_empty_distribution_ids_no_fallback(self): - """Empty distribution_ids=() must not fall back to legacy distribution_types.""" - data = Collection( - uuid=UUID, - version=1, - artifacts=[ - Artifact( - uuid=UUID, - name="Build SBOM", - type="BOM", - distribution_ids=(), - distribution_types=("binary",), - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], - ) - ], - ) - output = _capture(fmt_collection, data) - assert "binary" not in output - def test_inspect_artifact_shows_description_and_signature(self): """Inspect output should show description and signatureUrl for artifact formats.""" data = [ @@ -735,7 +710,7 @@ def test_inspect_artifact_shows_description_and_signature(self): "uuid": UUID2, "name": "VDR", "type": "VULNERABILITIES", - "distributionTypes": ["zip"], + "distributionIds": ["zip"], "formats": [ { "mediaType": "application/vnd.cyclonedx+xml", @@ -779,7 +754,8 @@ def test_format_output_dispatches_component(self): class TestReleasesFormatter: def test_fmt_releases(self): releases = [ - Release( + _legacy( + Release, uuid=UUID, version="1.0.0", component_name="App", @@ -787,7 +763,8 @@ def test_fmt_releases(self): release_date="2024-02-01T00:00:00Z", pre_release=False, ), - Release( + _legacy( + Release, uuid=UUID2, version="2.0.0", component_name="App", @@ -802,7 +779,7 @@ def test_fmt_releases(self): def test_format_output_releases_command(self): releases = [ - Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + _legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), ] output = _capture(format_output, releases, command="releases") assert "Component Releases" in output @@ -812,13 +789,21 @@ def test_format_output_releases_command(self): class TestCollectionsFormatter: def test_fmt_collections(self): cols = [ - Collection(uuid=UUID, version=1, date="2024-01-01T00:00:00Z", belongs_to="COMPONENT_RELEASE", artifacts=[]), - Collection( + _legacy( + Collection, + uuid=UUID, + version=1, + created_date="2024-01-01T00:00:00Z", + belongs_to="COMPONENT_RELEASE", + artifacts=[], + ), + _legacy( + Collection, uuid=UUID2, version=2, - date="2024-06-01T00:00:00Z", + created_date="2024-06-01T00:00:00Z", belongs_to="COMPONENT_RELEASE", - artifacts=[Artifact(uuid=UUID, name="SBOM", type="BOM", formats=[])], + artifacts=[_legacy(Artifact, uuid=UUID, name="SBOM", type="BOM", formats=[])], ), ] output = _capture(fmt_collections, cols) @@ -829,7 +814,7 @@ def test_fmt_collections(self): assert "1" in output def test_format_output_collections_command(self): - cols = [Collection(uuid=UUID, version=1, artifacts=[])] + cols = [_legacy(Collection, uuid=UUID, version=1, artifacts=[])] output = _capture(format_output, cols, command="collections") assert "Collections" in output @@ -926,7 +911,8 @@ class TestCollectionUpdateReasonComment: """Cover the update_reason.comment branch at _cli_fmt.py:239.""" def test_update_reason_without_comment(self): - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=2, update_reason=CollectionUpdateReason(type=CollectionUpdateReasonType.VEX_UPDATED), @@ -1030,7 +1016,8 @@ class TestArtifactVersionRendering: """Test that fmt_artifact renders version and created_date when present.""" def test_artifact_with_version(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="SBOM", type="BOM", @@ -1042,7 +1029,7 @@ def test_artifact_with_version(self): assert "3" in output def test_artifact_without_version(self): - data = Artifact(uuid=UUID, name="SBOM", type="BOM", formats=[]) + data = _legacy(Artifact, uuid=UUID, name="SBOM", type="BOM", formats=[]) output = _capture(fmt_artifact, data) assert "Version" not in output @@ -1074,12 +1061,15 @@ def test_empty_results(self): class TestFmtSearchComponentReleases: def test_renders_table(self): - data = PaginatedComponentReleaseResponse( + data = _legacy( + PaginatedComponentReleaseResponse, timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, total_results=1, - results=[Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", pre_release=True)], + results=[ + _legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", pre_release=True) + ], ) output = _capture(fmt_search_component_releases, data) assert "Component Releases" in output diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 17f35f2..b2b4d7e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -21,6 +21,7 @@ ProductRelease, Release, ) +from tests.spec_data import LEGACY class TestSearchProducts: @@ -648,62 +649,32 @@ class TestSpecV040Fields: def test_artifact_distribution_ids(self): art = Artifact.model_validate( - {"uuid": self._ART_UUID, "distributionIds": [self._DIST_UUID, self._DIST_UUID2], "formats": []} + { + "uuid": self._ART_UUID, + "type": "BOM", + "distributionIds": [self._DIST_UUID, self._DIST_UUID2], + "formats": [], + }, + context=LEGACY, ) assert art.distribution_ids == (self._DIST_UUID, self._DIST_UUID2) def test_artifact_distribution_ids_absent(self): - art = Artifact.model_validate({"uuid": self._ART_UUID, "formats": []}) + art = Artifact.model_validate({"uuid": self._ART_UUID, "type": "BOM", "formats": []}, context=LEGACY) assert art.distribution_ids is None - def test_artifact_backward_compat_distribution_types(self): - """Older servers may still send distributionTypes — must not break.""" - art = Artifact.model_validate( - {"uuid": self._ART_UUID, "distributionTypes": ["binary", "source"], "formats": []} - ) - assert art.distribution_types == ("binary", "source") - def test_release_distribution_id(self): from libtea.models import ReleaseDistribution dist = ReleaseDistribution.model_validate({"distributionId": self._DIST_UUID, "description": "core binary"}) assert dist.distribution_id == self._DIST_UUID - def test_release_distribution_backward_compat(self): - """Older servers may send distributionType instead of distributionId.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({"distributionType": "binary", "description": "old format"}) - assert dist.distribution_type == "binary" - assert dist.distribution_id is None - def test_artifact_distribution_ids_empty_array(self): """Empty distributionIds array should parse to empty tuple, not None.""" - art = Artifact.model_validate({"uuid": self._ART_UUID, "distributionIds": [], "formats": []}) - assert art.distribution_ids == () - - def test_artifact_coexistence_old_and_new_fields(self): - """A server sending both old and new fields should preserve both.""" art = Artifact.model_validate( - { - "uuid": self._ART_UUID, - "distributionIds": [self._DIST_UUID], - "distributionTypes": ["binary"], - "formats": [], - } + {"uuid": self._ART_UUID, "type": "BOM", "distributionIds": [], "formats": []}, context=LEGACY ) - assert art.distribution_ids == (self._DIST_UUID,) - assert art.distribution_types == ("binary",) - - def test_release_distribution_coexistence_old_and_new(self): - """Both distributionId and distributionType present — both preserved.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate( - {"distributionId": self._DIST_UUID, "distributionType": "binary", "description": "both fields"} - ) - assert dist.distribution_id == self._DIST_UUID - assert dist.distribution_type == "binary" + assert art.distribution_ids == () class TestDiscovery: diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 62045b2..ed17843 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -5,7 +5,8 @@ from libtea._validation import _validate, _validate_list from libtea.client import TeaClient -from tests.spec_data import LEGACY_BASE_URL, PRODUCT, V1_BASE_URL, product +from libtea.exceptions import TeaValidationError +from tests.spec_data import COMPONENT_RELEASE, LEGACY_BASE_URL, PRODUCT, V1_BASE_URL, product class TestSpecVersion: @@ -50,3 +51,13 @@ def test_validate_list(self, request, fixture, base_url, expected): with patch("libtea.client._validate_list", wraps=_validate_list) as spy: client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") assert spy.call_args.kwargs == {"legacy": expected} + + +@responses.activate +def test_v1_client_rejects_incomplete_collection(): + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}/collection/latest", + json={"uuid": COMPONENT_RELEASE, "version": 1}, + ) + with pytest.raises(TeaValidationError, match="Collection"): + TeaClient(V1_BASE_URL).get_component_release_collection_latest(COMPONENT_RELEASE) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index de5bb4c..ce0e72a 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -23,7 +23,7 @@ "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7"}], "distributions": [ { - "distributionType": "zip", + "distributionId": "6a0d58e1-4896-4f1e-83f7-5feb6c032537", "description": "Core binary distribution, zip archive", "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7?type=zip"}], "checksums": [ @@ -81,8 +81,7 @@ } -# v0.4.0 payload variants: distributionId replaces distributionType, -# distributionIds replaces distributionTypes on artifacts. +# v0.4.0 payload variants with distributionId and artifact distributionIds. TOMCAT_RELEASE_V040 = { "uuid": "605d0ecb-1057-40e4-9abf-c400b10f0345", "version": "11.0.7", @@ -150,7 +149,7 @@ def test_full_consumer_flow(self, base_url): # Step 2: Get component release with collection cr = client.get_component_release(release_uuid) assert cr.release.version == "11.0.7" - assert cr.release.distributions[0].distribution_type == "zip" + assert cr.release.distributions[0].distribution_id == "6a0d58e1-4896-4f1e-83f7-5feb6c032537" assert cr.release.distributions[0].checksums[0].algorithm_type == ChecksumAlgorithm.SHA_256 # Step 3: Get latest collection @@ -168,7 +167,7 @@ def test_full_consumer_flow(self, base_url): @responses.activate def test_v040_server_payload(self, base_url): - """v0.4.0 server sends distributionId and distributionIds instead of legacy fields.""" + """v0.4.0 server sends distributionId and distributionIds.""" release_uuid = TOMCAT_RELEASE_V040["uuid"] responses.get( @@ -182,42 +181,6 @@ def test_v040_server_payload(self, base_url): with TeaClient(base_url=base_url) as client: cr = client.get_component_release(release_uuid) - # New v0.4.0 field assert cr.release.distributions[0].distribution_id == "d7a8e9f0-1234-5678-abcd-ef0123456789" - # Legacy field absent - assert cr.release.distributions[0].distribution_type is None - - # Artifact uses distributionIds (new) not distributionTypes (legacy) sbom = cr.latest_collection.artifacts[0] assert sbom.distribution_ids == ("d7a8e9f0-1234-5678-abcd-ef0123456789",) - assert sbom.distribution_types is None - - @responses.activate - def test_mixed_server_payload(self, base_url): - """Transitional server sends both old and new fields — both preserved.""" - mixed_release = { - **TOMCAT_RELEASE_V040, - "distributions": [ - { - "distributionId": "d7a8e9f0-1234-5678-abcd-ef0123456789", - "distributionType": "zip", - "description": "Both fields present", - } - ], - } - release_uuid = mixed_release["uuid"] - - responses.get( - f"{base_url}/componentRelease/{release_uuid}", - json={ - "release": mixed_release, - "latestCollection": LOG4J_COLLECTION, - }, - ) - - with TeaClient(base_url=base_url) as client: - cr = client.get_component_release(release_uuid) - dist = cr.release.distributions[0] - # Both fields preserved - assert dist.distribution_id == "d7a8e9f0-1234-5678-abcd-ef0123456789" - assert dist.distribution_type == "zip" diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 0aa1718..a9299d7 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -120,7 +120,7 @@ _COLLECTION_JSON = { "version": 1, - "artifacts": [{"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}], + "artifacts": [{"uuid": _ARTIFACT_UUID, "name": "sbom.json", "type": "BOM", "formats": []}], } _COLLECTION_NO_ARTIFACTS_JSON = { @@ -154,6 +154,7 @@ _ARTIFACT_JSON = { "uuid": _ARTIFACT_UUID, "name": "sbom.json", + "type": "BOM", "formats": [], } @@ -734,43 +735,6 @@ def test_does_not_overwrite_existing_artifact_uuid(self): check_product_release_collection_latest(client, ctx) assert ctx.artifact_uuid == "existing-uuid" - @responses.activate - def test_skips_artifact_with_no_uuid(self): - collection_json = { - "version": 1, - "artifacts": [ - {"name": "no-uuid.json", "formats": []}, - {"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}, - ], - } - responses.get( - f"{BASE_URL}/productRelease/{_RELEASE_UUID}/collection/latest", - json=collection_json, - ) - client = _make_client() - ctx = CheckContext(product_release_uuid=_RELEASE_UUID) - check_product_release_collection_latest(client, ctx) - assert ctx.artifact_uuid == _ARTIFACT_UUID - - @responses.activate - def test_all_artifacts_without_uuid(self): - collection_json = { - "version": 1, - "artifacts": [ - {"name": "no-uuid-1.json", "formats": []}, - {"name": "no-uuid-2.json", "formats": []}, - ], - } - responses.get( - f"{BASE_URL}/productRelease/{_RELEASE_UUID}/collection/latest", - json=collection_json, - ) - client = _make_client() - ctx = CheckContext(product_release_uuid=_RELEASE_UUID) - result = check_product_release_collection_latest(client, ctx) - assert result.status == CheckStatus.PASS - assert ctx.artifact_uuid is None - class TestCheckProductReleaseCollections: @responses.activate @@ -966,49 +930,6 @@ def test_does_not_overwrite_existing_artifact_uuid(self): check_component_release(client, ctx) assert ctx.artifact_uuid == "existing-uuid" - @responses.activate - def test_skips_artifact_with_no_uuid(self): - json_data = { - "release": _RELEASE_JSON, - "latestCollection": { - "version": 1, - "artifacts": [ - {"name": "no-uuid.json", "formats": []}, - {"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}, - ], - }, - } - responses.get( - f"{BASE_URL}/componentRelease/{_COMPONENT_RELEASE_UUID}", - json=json_data, - ) - client = _make_client() - ctx = CheckContext(component_release_uuid=_COMPONENT_RELEASE_UUID) - check_component_release(client, ctx) - assert ctx.artifact_uuid == _ARTIFACT_UUID - - @responses.activate - def test_all_artifacts_without_uuid(self): - json_data = { - "release": _RELEASE_JSON, - "latestCollection": { - "version": 1, - "artifacts": [ - {"name": "no-uuid-1.json", "formats": []}, - {"name": "no-uuid-2.json", "formats": []}, - ], - }, - } - responses.get( - f"{BASE_URL}/componentRelease/{_COMPONENT_RELEASE_UUID}", - json=json_data, - ) - client = _make_client() - ctx = CheckContext(component_release_uuid=_COMPONENT_RELEASE_UUID) - result = check_component_release(client, ctx) - assert result.status == CheckStatus.PASS - assert ctx.artifact_uuid is None - class TestCheckComponentReleaseCollections: @responses.activate @@ -1066,7 +987,7 @@ def test_fail_on_server_error(self): @responses.activate def test_pass_artifact_without_name(self): - artifact_no_name = {"uuid": _ARTIFACT_UUID, "formats": []} + artifact_no_name = {"uuid": _ARTIFACT_UUID, "type": "BOM", "formats": []} responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_no_name) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) @@ -1366,6 +1287,7 @@ def test_pass_with_formats(self): artifact_with_formats = { "uuid": _ARTIFACT_UUID, "name": "sbom.json", + "type": "BOM", "formats": [{"mediaType": "application/json", "url": "https://example.com/sbom.json"}], } responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_with_formats) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 8f375fb..7a96ff6 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,8 +1,11 @@ +from datetime import datetime, timezone + import pytest from pydantic import ValidationError from libtea.models import ( CLE, + Artifact, ArtifactFormat, ArtifactType, Checksum, @@ -26,6 +29,7 @@ Release, TeiType, ) +from tests.spec_data import COMPONENT_RELEASE, LEGACY, V1_ARTIFACT, V1_COLLECTION class TestEnums: @@ -241,7 +245,7 @@ def test_release_from_json(self): "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7"}], "distributions": [ { - "distributionType": "zip", + "distributionId": "zip", "description": "Core binary distribution", "checksums": [ { @@ -253,9 +257,9 @@ def test_release_from_json(self): } ], } - release = Release.model_validate(data) + release = Release.model_validate(data, context=LEGACY) assert release.version == "11.0.7" - assert release.distributions[0].distribution_type == "zip" + assert release.distributions[0].distribution_id == "zip" assert release.distributions[0].checksums[0].algorithm_type == ChecksumAlgorithm.SHA_256 @@ -283,7 +287,7 @@ def test_collection_from_json(self): } ], } - collection = Collection.model_validate(data) + collection = Collection.model_validate(data, context=LEGACY) assert collection.version == 3 assert collection.belongs_to == CollectionBelongsTo.COMPONENT_RELEASE assert collection.update_reason.type == CollectionUpdateReasonType.ARTIFACT_UPDATED @@ -298,7 +302,7 @@ def test_release_minimal_fields(self): "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z", } - release = Release.model_validate(data) + release = Release.model_validate(data, context=LEGACY) assert release.release_date is None assert release.pre_release is None assert release.component is None @@ -307,15 +311,15 @@ def test_release_minimal_fields(self): def test_collection_minimal_fields(self): data = {"uuid": "c-1", "version": 1} - collection = Collection.model_validate(data) - assert collection.date is None + collection = Collection.model_validate(data, context=LEGACY) + assert collection.created_date is None assert collection.belongs_to is None assert collection.update_reason is None assert collection.artifacts == () def test_collection_all_fields_optional(self): - """Per TEA spec, all Collection fields are optional.""" - collection = Collection.model_validate({}) + """Per TEA 0.4.0, all Collection fields are optional.""" + collection = Collection.model_validate({}, context=LEGACY) assert collection.uuid is None assert collection.version is None assert collection.artifacts == () @@ -323,18 +327,18 @@ def test_collection_all_fields_optional(self): def test_collection_version_rejects_zero(self): """TEA spec says versions start with 1.""" with pytest.raises(ValidationError): - Collection.model_validate({"version": 0}) + Collection.model_validate({"version": 0}, context=LEGACY) def test_collection_version_rejects_negative(self): with pytest.raises(ValidationError): - Collection.model_validate({"version": -1}) + Collection.model_validate({"version": -1}, context=LEGACY) def test_artifact_format_minimal_fields(self): data = { "mediaType": "application/json", "url": "https://example.com/sbom.json", } - fmt = ArtifactFormat.model_validate(data) + fmt = ArtifactFormat.model_validate(data, context=LEGACY) assert fmt.description is None assert fmt.signature_url is None assert fmt.checksums == () @@ -577,25 +581,9 @@ def test_version_specifier_empty_rejected(self): class TestInteropCompat: """Tests for interoperability with older/non-standard server responses.""" - def test_artifact_format_accepts_mime_type_as_alias(self): - """Servers may send ``mimeType`` instead of ``mediaType``.""" - fmt = ArtifactFormat.model_validate({"mimeType": "application/json", "url": "https://example.com/sbom.json"}) - assert fmt.media_type == "application/json" - - def test_artifact_format_prefers_media_type_over_mime_type(self): - """When both are present, ``mediaType`` takes precedence.""" - fmt = ArtifactFormat.model_validate( - { - "mediaType": "application/xml", - "mimeType": "text/plain", - "url": "https://example.com/sbom.xml", - } - ) - assert fmt.media_type == "application/xml" - def test_artifact_format_media_type_optional(self): - """mediaType is not required by the spec.""" - fmt = ArtifactFormat.model_validate({"url": "https://example.com/sbom.json"}) + """mediaType is not required by the 0.4.0 spec.""" + fmt = ArtifactFormat.model_validate({"url": "https://example.com/sbom.json"}, context=LEGACY) assert fmt.media_type is None def test_artifact_format_url_optional(self): @@ -603,78 +591,31 @@ def test_artifact_format_url_optional(self): fmt = ArtifactFormat.model_validate({"mediaType": "application/json"}) assert fmt.url is None - def test_release_distribution_type_optional(self): - """distributionType can be null (not required by spec).""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({"distributionType": None}) - assert dist.distribution_type is None - - def test_release_distribution_type_absent(self): - """distributionType can be entirely absent.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({}) - assert dist.distribution_type is None - def test_identifier_all_fields_optional(self): - """Per spec, Identifier has no required fields.""" - ident = Identifier.model_validate({}) + """Per TEA 0.4.0, Identifier has no required fields.""" + ident = Identifier.model_validate({}, context=LEGACY) assert ident.id_type is None assert ident.id_value is None def test_identifier_partial_fields(self): - """Per spec, only idType without idValue is valid.""" - ident = Identifier.model_validate({"idType": "PURL"}) + """Per TEA 0.4.0, only idType without idValue is valid.""" + ident = Identifier.model_validate({"idType": "PURL"}, context=LEGACY) assert ident.id_type == "PURL" assert ident.id_value is None - def test_checksum_all_fields_optional(self): - """Per spec, Checksum has no required fields.""" - cs = Checksum.model_validate({}) - assert cs.algorithm_type is None - assert cs.algorithm_value is None - - def test_checksum_partial_algorithm_type_only(self): - """Per spec, algType without algValue is valid.""" - cs = Checksum.model_validate({"algType": "SHA-256"}) - assert cs.algorithm_type == ChecksumAlgorithm.SHA_256 - assert cs.algorithm_value is None - - def test_artifact_all_fields_optional(self): - """Per spec, Artifact has no required fields.""" - from libtea.models import Artifact - - art = Artifact.model_validate({}) - assert art.uuid is None - assert art.name is None - assert art.type is None - assert art.version is None - assert art.created_date is None - assert art.formats == () - - def test_artifact_partial_fields(self): - """Per spec, uuid alone is valid.""" - from libtea.models import Artifact - - art = Artifact.model_validate({"uuid": "abc-123"}) - assert art.uuid == "abc-123" - assert art.name is None - assert art.type is None - def test_collection_update_reason_all_fields_optional(self): - """Per spec, CollectionUpdateReason has no required fields.""" + """Per TEA 0.4.0, CollectionUpdateReason has no required fields.""" from libtea.models import CollectionUpdateReason - reason = CollectionUpdateReason.model_validate({}) + reason = CollectionUpdateReason.model_validate({}, context=LEGACY) assert reason.type is None assert reason.comment is None def test_collection_update_reason_comment_only(self): - """Per spec, comment without type is valid.""" + """Per TEA 0.4.0, comment without type is valid.""" from libtea.models import CollectionUpdateReason - reason = CollectionUpdateReason.model_validate({"comment": "Updated manually"}) + reason = CollectionUpdateReason.model_validate({"comment": "Updated manually"}, context=LEGACY) assert reason.type is None assert reason.comment == "Updated manually" @@ -706,11 +647,8 @@ def test_extra_fields_tolerated(self): class TestComplianceDocumentType: - def test_fedramp_value(self): - assert ComplianceDocumentType.FEDRAMP == "FedRAMP" - def test_all_members_count(self): - assert len(ComplianceDocumentType) == 20 + assert len(ComplianceDocumentType) == 21 class TestPaginatedComponentResponse: @@ -761,7 +699,7 @@ def test_basic(self): } ], } - resp = PaginatedComponentReleaseResponse.model_validate(data) + resp = PaginatedComponentReleaseResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert isinstance(resp.results[0], Release) assert resp.results[0].version == "1.0.0" @@ -777,3 +715,45 @@ def test_empty_results(self): resp = PaginatedComponentReleaseResponse.model_validate(data) assert resp.total_results == 0 assert resp.results == () + + +class TestV1RequiredFields: + def test_collection_missing_created_date_rejected(self): + data = {k: v for k, v in V1_COLLECTION.items() if k != "createdDate"} + with pytest.raises(ValidationError, match="created_date"): + Collection.model_validate(data) + + def test_collection_legacy_date_mapped(self): + c = Collection.model_validate({"uuid": COMPONENT_RELEASE, "date": "2024-12-15T00:00:00Z"}, context=LEGACY) + assert c.created_date == datetime(2024, 12, 15, tzinfo=timezone.utc) + + def test_collection_date_key_not_mapped_on_v1(self): + data = {**{k: v for k, v in V1_COLLECTION.items() if k != "createdDate"}, "date": "2024-12-15T00:00:00Z"} + with pytest.raises(ValidationError, match="created_date"): + Collection.model_validate(data) + + def test_nested_artifact_enforced(self): + bad = {**V1_COLLECTION, "artifacts": [{k: v for k, v in V1_ARTIFACT.items() if k != "version"}]} + with pytest.raises(ValidationError) as exc: + Collection.model_validate(bad) + assert exc.value.errors()[0]["loc"][:2] == ("artifacts", 0) + + def test_identifier_both_fields_v1_only(self): + with pytest.raises(ValidationError, match="id_value"): + Identifier.model_validate({"idType": "PURL"}) + assert Identifier.model_validate({"idType": "PURL"}, context=LEGACY).id_value is None + + def test_checksum_fields_required_in_both(self): + with pytest.raises(ValidationError): + Checksum.model_validate({"algType": "SHA-256"}, context=LEGACY) + + def test_format_with_url_requires_checksums(self): + with pytest.raises(ValidationError, match="checksums"): + ArtifactFormat.model_validate({"mediaType": "application/json", "url": "https://x.example.com/a"}) + + def test_artifact_requires_formats_entry(self): + with pytest.raises(ValidationError, match="formats"): + Artifact.model_validate({**V1_ARTIFACT, "formats": []}) + + def test_fedramp_value(self): + assert ComplianceDocumentType.FEDRAMP == "FEDRAMP" From 9f001173e7fbcff18e5c1b70f35e4351fee63cea Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:06:05 +0500 Subject: [PATCH 04/29] feat!: reject MD5 checksums from TEA 1.x servers --- src/libtea/models.py | 12 +++++++++++- tests/client/test_client.py | 2 +- tests/client/test_client_v1.py | 22 +++++++++++++++++++++- tests/spec_data.py | 1 + tests/unit/test_models.py | 12 ++++++++++-- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/libtea/models.py b/src/libtea/models.py index 87a2d1f..c2d00a9 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -120,6 +120,9 @@ class ChecksumAlgorithm(StrEnum): Values use hyphen form (e.g. ``SHA-256``). The Checksum model's validator normalizes underscore form (``SHA_256``) to hyphen form automatically. + + ``MD5`` is 0.4.0 only: TEA 1.0.0 dropped it, so it is rejected unless the + model is validated with the legacy context. """ MD5 = "MD5" @@ -222,7 +225,8 @@ class Checksum(_TeaModel): """A checksum with algorithm type and hex value. The ``algorithm_type`` validator normalizes both hyphen form (``SHA-256``) and - underscore form (``SHA_256``) to the canonical hyphen form. + underscore form (``SHA_256``) to the canonical hyphen form. TEA 1.0.0 does not + allow ``MD5``; 0.4.0 servers may still send it. """ # Manual aliases because the TEA spec wire format uses abbreviated names @@ -245,6 +249,12 @@ def normalize_algorithm_type(cls, v: str) -> str: return mapped return v + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if self.algorithm_type is ChecksumAlgorithm.MD5: + problems.append("MD5 is not a TEA 1.0.0 checksum algorithm") + return problems + # --- Domain objects --- diff --git a/tests/client/test_client.py b/tests/client/test_client.py index b2b4d7e..845d242 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1173,7 +1173,7 @@ def test_md5_checksum_warns(self, client, tmp_path): content = b"test content" responses.get("https://artifacts.example.com/sbom.json", body=content) md5 = hashlib.md5(content).hexdigest() - checksums = [Checksum(algorithm_type=ChecksumAlgorithm.MD5, algorithm_value=md5)] + checksums = [Checksum.model_validate({"algType": "MD5", "algValue": md5}, context=LEGACY)] dest = tmp_path / "sbom.json" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index ed17843..49234ee 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -6,7 +6,16 @@ from libtea._validation import _validate, _validate_list from libtea.client import TeaClient from libtea.exceptions import TeaValidationError -from tests.spec_data import COMPONENT_RELEASE, LEGACY_BASE_URL, PRODUCT, V1_BASE_URL, product +from tests.spec_data import ( + ARTIFACT, + COMPONENT_RELEASE, + LEGACY_BASE_URL, + MD5_HEX, + PRODUCT, + V1_ARTIFACT, + V1_BASE_URL, + product, +) class TestSpecVersion: @@ -61,3 +70,14 @@ def test_v1_client_rejects_incomplete_collection(): ) with pytest.raises(TeaValidationError, match="Collection"): TeaClient(V1_BASE_URL).get_component_release_collection_latest(COMPONENT_RELEASE) + + +@responses.activate +def test_v1_artifact_with_md5_rejected(): + art = { + **V1_ARTIFACT, + "formats": [{**V1_ARTIFACT["formats"][0], "checksums": [{"algType": "MD5", "algValue": MD5_HEX}]}], + } + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest", json=art) + with pytest.raises(TeaValidationError, match="MD5"): + TeaClient(V1_BASE_URL).get_artifact(ARTIFACT) diff --git a/tests/spec_data.py b/tests/spec_data.py index e59aa5c..f66c6b1 100644 --- a/tests/spec_data.py +++ b/tests/spec_data.py @@ -8,6 +8,7 @@ COMPONENT_RELEASE = "da89e38e-95e7-44ca-aa7d-f3b6b34c7fab" ARTIFACT = "1cb47b95-8bf8-3bad-a5a4-0d54d86e10ce" SHA256_HEX = "e04c9d55986d7194822eaa4f8115a77f801844d807ad6e0d454ac31dd41861e5" +MD5_HEX = "d41d8cd98f00b204e9800998ecf8427e" V1_ARTIFACT = { "uuid": ARTIFACT, diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 7a96ff6..7c4bd8b 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -29,7 +29,7 @@ Release, TeiType, ) -from tests.spec_data import COMPONENT_RELEASE, LEGACY, V1_ARTIFACT, V1_COLLECTION +from tests.spec_data import COMPONENT_RELEASE, LEGACY, MD5_HEX, V1_ARTIFACT, V1_COLLECTION class TestEnums: @@ -142,7 +142,7 @@ def test_underscore_to_value(self, raw, expected_member): def test_valid_values_pass_through(self): for member in ChecksumAlgorithm: - cs = Checksum.model_validate({"algType": member.value, "algValue": "aabbcc"}) + cs = Checksum.model_validate({"algType": member.value, "algValue": "aabbcc"}, context=LEGACY) assert cs.algorithm_type == member @@ -755,5 +755,13 @@ def test_artifact_requires_formats_entry(self): with pytest.raises(ValidationError, match="formats"): Artifact.model_validate({**V1_ARTIFACT, "formats": []}) + def test_md5_rejected_on_v1(self): + with pytest.raises(ValidationError, match="MD5"): + Checksum.model_validate({"algType": "MD5", "algValue": MD5_HEX}) + + def test_md5_accepted_legacy(self): + cs = Checksum.model_validate({"algType": "MD5", "algValue": MD5_HEX}, context=LEGACY) + assert cs.algorithm_type is ChecksumAlgorithm.MD5 + def test_fedramp_value(self): assert ComplianceDocumentType.FEDRAMP == "FEDRAMP" From 7fde20728eae439d01ac4dfbb09c04fd5a249165 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:07:19 +0500 Subject: [PATCH 05/29] fix: enforce remaining TEA 1.0.0 presence rules, fail closed on CLI checksum filter --- src/libtea/cli.py | 2 +- src/libtea/models.py | 16 ++++++++++++---- tests/cli/test_cli.py | 33 +++++++++++++++++++++++++++++++++ tests/client/test_client_v1.py | 2 +- tests/unit/test_models.py | 29 ++++++++++++++++++++++++++++- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/libtea/cli.py b/src/libtea/cli.py index a0964d7..444fd8a 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -1057,7 +1057,7 @@ def _download_from_tei( filename = _artifact_filename(fmt, artifact, art_idx) filename = _deduplicate_filename(filename, seen_filenames) dest_path = dest_dir / filename - checksums = [cs for cs in fmt.checksums if cs.algorithm_type and cs.algorithm_value] + checksums = list(fmt.checksums) attempted += 1 if dry_run: if not quiet: diff --git a/src/libtea/models.py b/src/libtea/models.py index c2d00a9..4227b90 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -274,6 +274,7 @@ class ArtifactFormat(_TeaModel): """A TEA artifact in a specific format with download URL and checksums. TEA 1.0.0 requires ``media_type``, and at least one checksum when ``url`` is set. + ``checksums`` may be omitted but, when present, must not be empty. """ _v1_required = ("media_type",) @@ -286,8 +287,8 @@ class ArtifactFormat(_TeaModel): def _v1_problems(self) -> list[str]: problems = super()._v1_problems() - if self.url is not None and not self.checksums: - problems.append("'checksums' must not be empty when 'url' is set") + if not self.checksums and (self.url is not None or "checksums" in self.model_fields_set): + problems.append("'checksums' must not be empty when 'url' is set or 'checksums' is present") return problems @@ -331,8 +332,9 @@ class Collection(_TeaModel): The UUID matches the owning component or product release. The version integer starts at 1 and increments on each content change. - TEA 1.0.0 requires ``uuid``, ``version``, ``created_date``, ``belongs_to`` and - ``update_reason``; 0.4.0 requires none and names the creation timestamp ``date``. + TEA 1.0.0 requires ``uuid``, ``version``, ``created_date``, ``belongs_to``, + ``update_reason`` and the ``artifacts`` key (an empty list is valid); 0.4.0 + requires none and names the creation timestamp ``date``. Attributes: uuid: UUID of the owning component or product release. @@ -352,6 +354,12 @@ class Collection(_TeaModel): update_reason: CollectionUpdateReason | None = None artifacts: tuple[Artifact, ...] = () + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if "artifacts" not in self.model_fields_set: + problems.append("missing required field 'artifacts'") + return problems + @model_validator(mode="before") @classmethod def _map_legacy_date(cls, data: Any, info: ValidationInfo) -> Any: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index e74a468..08f881d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1669,6 +1669,39 @@ def test_tei_mode_all_downloads_fail(self, tmp_path): assert result.exit_code == 1 assert "All 1 artifact download(s) failed" in _all_output(result) + @responses.activate + def test_tei_mode_empty_checksum_value_fails_closed(self, tmp_path): + """A checksum with an empty value must fail verification, not be skipped.""" + tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + artifact_url = "https://cdn.example.com/sbom.json" + responses.get( + f"{BASE_URL}/discovery", + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["0.4.0"]}]}], + ) + responses.get( + f"{BASE_URL}/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": [{"algType": "SHA-256", "algValue": ""}]}], + } + ], + }, + ) + responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}') + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--base-url", BASE_URL]) + assert result.exit_code == 1 + assert "Checksum FAILED for sbom.json" in _all_output(result) + assert "Downloaded sbom.json" not in _all_output(result) + assert not (dest / "sbom.json").exists() + @responses.activate def test_url_mode_still_works(self, tmp_path): """Direct URL mode is unchanged.""" diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 49234ee..dcfa43c 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -68,7 +68,7 @@ def test_v1_client_rejects_incomplete_collection(): f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}/collection/latest", json={"uuid": COMPONENT_RELEASE, "version": 1}, ) - with pytest.raises(TeaValidationError, match="Collection"): + with pytest.raises(TeaValidationError, match="TEA 1.0.0"): TeaClient(V1_BASE_URL).get_component_release_collection_latest(COMPONENT_RELEASE) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 7c4bd8b..6d6dd86 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -26,10 +26,19 @@ PaginatedComponentResponse, PaginatedProductResponse, Product, + ProductRelease, Release, TeiType, ) -from tests.spec_data import COMPONENT_RELEASE, LEGACY, MD5_HEX, V1_ARTIFACT, V1_COLLECTION +from tests.spec_data import ( + COMPONENT_RELEASE, + LEGACY, + MD5_HEX, + V1_ARTIFACT, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, +) class TestEnums: @@ -755,6 +764,24 @@ def test_artifact_requires_formats_entry(self): with pytest.raises(ValidationError, match="formats"): Artifact.model_validate({**V1_ARTIFACT, "formats": []}) + def test_collection_requires_artifacts_key(self): + data = {k: v for k, v in V1_COLLECTION.items() if k != "artifacts"} + with pytest.raises(ValidationError, match="artifacts"): + Collection.model_validate(data) + assert Collection.model_validate({**data, "artifacts": []}).artifacts == () + assert Collection.model_validate(data, context=LEGACY).artifacts == () + + def test_format_checksums_present_must_not_be_empty(self): + for extra in ({}, {"url": "https://x.example.com/a"}): + with pytest.raises(ValidationError, match="checksums"): + ArtifactFormat.model_validate({"mediaType": "application/json", "checksums": [], **extra}) + assert ArtifactFormat.model_validate({"mediaType": "application/json"}).checksums == () + + def test_valid_v1_documents_pass(self): + Collection.model_validate(V1_COLLECTION) + Release.model_validate(V1_RELEASE) + ProductRelease.model_validate(V1_PRODUCT_RELEASE) + def test_md5_rejected_on_v1(self): with pytest.raises(ValidationError, match="MD5"): Checksum.model_validate({"algType": "MD5", "algValue": MD5_HEX}) From de8327286682e22a7f6087e7ef3abad742a2c0cf Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:11:17 +0500 Subject: [PATCH 06/29] fix: allow user-supplied MD5 in CLI --checksum --- src/libtea/cli.py | 5 ++++- src/libtea/models.py | 2 +- tests/cli/test_cli.py | 22 ++++++++++++++++++++++ tests/unit/test_models.py | 4 ++-- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 444fd8a..1cedc29 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -1183,7 +1183,10 @@ def download( _error( f"Unknown checksum algorithm: {alg!r}. Supported: {', '.join(e.value for e in ChecksumAlgorithm)}" ) - checksums.append(Checksum(algorithm_type=alg_enum, algorithm_value=value)) + # The user supplied this hash; it is not server data, so TEA 1.0.0 response rules (e.g. no MD5) don't apply. + checksums.append( + Checksum.model_validate({"algType": alg_enum, "algValue": value}, context={"tea_legacy": True}) + ) try: with _client_session( diff --git a/src/libtea/models.py b/src/libtea/models.py index 4227b90..e1326ab 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -288,7 +288,7 @@ class ArtifactFormat(_TeaModel): def _v1_problems(self) -> list[str]: problems = super()._v1_problems() if not self.checksums and (self.url is not None or "checksums" in self.model_fields_set): - problems.append("'checksums' must not be empty when 'url' is set or 'checksums' is present") + problems.append("'checksums' must not be empty") return problems diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 08f881d..d887d26 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -333,6 +333,28 @@ def test_download_with_checksum(self, tmp_path): assert result.exit_code == 0 assert dest.exists() + @responses.activate + def test_download_with_md5_checksum(self, tmp_path): + """A user-supplied MD5 is accepted (it is not server data) and still warns as a weak hash.""" + artifact_url = "https://cdn.example.com/sbom.json" + responses.get(artifact_url, body=b"{}") + dest = tmp_path / "sbom.json" + with pytest.warns(UserWarning, match="weak hash"): + result = runner.invoke( + app, + [ + "download", + artifact_url, + str(dest), + "--checksum", + "MD5:99914b932bd37a50b983c5e7c90ae93b", + "--base-url", + BASE_URL, + ], + ) + assert result.exit_code == 0 + assert dest.read_bytes() == b"{}" + def test_download_invalid_checksum_format(self, tmp_path): dest = tmp_path / "sbom.json" result = runner.invoke( diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6d6dd86..fc394df 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -766,14 +766,14 @@ def test_artifact_requires_formats_entry(self): def test_collection_requires_artifacts_key(self): data = {k: v for k, v in V1_COLLECTION.items() if k != "artifacts"} - with pytest.raises(ValidationError, match="artifacts"): + with pytest.raises(ValidationError, match="missing required field 'artifacts'"): Collection.model_validate(data) assert Collection.model_validate({**data, "artifacts": []}).artifacts == () assert Collection.model_validate(data, context=LEGACY).artifacts == () def test_format_checksums_present_must_not_be_empty(self): for extra in ({}, {"url": "https://x.example.com/a"}): - with pytest.raises(ValidationError, match="checksums"): + with pytest.raises(ValidationError, match="'checksums' must not be empty"): ArtifactFormat.model_validate({"mediaType": "application/json", "checksums": [], **extra}) assert ArtifactFormat.model_validate({"mediaType": "application/json"}).checksums == () From a5a3506a2df8e2711851fcee14f020465792aa68 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:24:15 +0500 Subject: [PATCH 07/29] feat!: token-based pagination for TEA 1.0.0, offset tokens for 0.4.0 --- docs/cli.md | 18 +-- src/libtea/__init__.py | 5 + src/libtea/_cli_fmt.py | 10 +- src/libtea/_validation.py | 15 +-- src/libtea/cli.py | 30 ++--- src/libtea/client.py | 156 ++++++++++++++------------ src/libtea/conformance/_checks.py | 36 +++--- src/libtea/models.py | 68 +++++++---- src/libtea/protocols.py | 18 +-- tests/cli/test_cli.py | 2 +- tests/cli/test_cli_fmt.py | 35 +++++- tests/client/test_client.py | 20 ++-- tests/client/test_client_v1.py | 141 ++++++++++++++++++++++- tests/unit/test_conformance_checks.py | 16 +++ tests/unit/test_models.py | 62 +++++++++- tests/unit/test_validation.py | 27 ++--- 16 files changed, 471 insertions(+), 188 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 4f069bf..d8beec5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -95,7 +95,7 @@ tea-cli inspect [--max-components N] TEI Search for products by identifier. ``` -tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-products --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` **Options:** @@ -104,8 +104,8 @@ tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--pag |--------|-------------| | `--id-type` *TYPE* | Identifier type: `CPE`, `TEI`, or `PURL`. | | `--id-value` *VALUE* | Identifier value to search for. | -| `--page-offset` *N* | Page offset for pagination (default: 0). | -| `--page-size` *N* | Page size for pagination (default: 100). | +| `--page-token` *TOKEN* | `nextPageToken` from the previous page; an integer offset on TEA 0.x servers (default: first page). | +| `--page-size` *N* | Page size for pagination (default: 100; max 100 on TEA 1.x). | --- @@ -114,7 +114,7 @@ tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--pag Search for product releases by identifier. ``` -tea-cli search-releases --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-releases --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. @@ -136,15 +136,15 @@ tea-cli get-product UUID List releases for a product UUID. ``` -tea-cli get-product-releases UUID [--page-offset N] [--page-size N] +tea-cli get-product-releases UUID [--page-token TOKEN] [--page-size N] ``` **Options:** | Option | Description | |--------|-------------| -| `--page-offset` *N* | Page offset for pagination (default: 0). | -| `--page-size` *N* | Page size for pagination (default: 100). | +| `--page-token` *TOKEN* | `nextPageToken` from the previous page; an integer offset on TEA 0.x servers (default: first page). | +| `--page-size` *N* | Page size for pagination (default: 100; max 100 on TEA 1.x). | --- @@ -222,7 +222,7 @@ tea-cli list-collections [--component] UUID Search for components by identifier. ``` -tea-cli search-components --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-components --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. @@ -234,7 +234,7 @@ Options are the same as `search-products`. Search for component releases by identifier. ``` -tea-cli search-component-releases --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-component-releases --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. diff --git a/src/libtea/__init__.py b/src/libtea/__init__.py index 22fe346..964243f 100644 --- a/src/libtea/__init__.py +++ b/src/libtea/__init__.py @@ -138,6 +138,9 @@ from libtea.models import ( IdentifierType as IdentifierType, ) + from libtea.models import ( + PaginatedCollectionResponse as PaginatedCollectionResponse, + ) from libtea.models import ( PaginatedComponentReleaseResponse as PaginatedComponentReleaseResponse, ) @@ -225,6 +228,7 @@ "ErrorType", "Identifier", "IdentifierType", + "PaginatedCollectionResponse", "PaginatedComponentReleaseResponse", "PaginatedComponentResponse", "PaginatedProductReleaseResponse", @@ -287,6 +291,7 @@ "ErrorType": "libtea.models", "Identifier": "libtea.models", "IdentifierType": "libtea.models", + "PaginatedCollectionResponse": "libtea.models", "PaginatedComponentReleaseResponse": "libtea.models", "PaginatedComponentResponse": "libtea.models", "PaginatedProductReleaseResponse": "libtea.models", diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index 795c9e3..3e2f16c 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -81,11 +81,15 @@ def _pagination_header( console: Console, ) -> None: """Render a dim pagination summary line.""" - if not data.results: + if data.total_results is None: # TEA 1.x: no totals, only a continuation token + console.print(Text(f"{len(data.results)} result(s)", style="dim")) + if data.has_next: + console.print(Text(f"More results: --page-token {data.next_page_token}", style="dim")) + elif not data.results: console.print(Text(f"No results (total: {data.total_results})", style="dim")) else: - end = data.page_start_index + len(data.results) - console.print(Text(f"Results {data.page_start_index + 1}-{end} of {data.total_results}", style="dim")) + start = data.page_start_index or 0 # 0.4.0 default + console.print(Text(f"Results {start + 1}-{start + len(data.results)} of {data.total_results}", style="dim")) def _distributions_table(distributions: Sequence[ReleaseDistribution], *, console: Console) -> None: diff --git a/src/libtea/_validation.py b/src/libtea/_validation.py index 903e8fd..e696a3c 100644 --- a/src/libtea/_validation.py +++ b/src/libtea/_validation.py @@ -68,19 +68,14 @@ def _validate_path_segment(value: str, name: str = "uuid") -> str: return str(parsed) -_MAX_PAGE_SIZE = 10000 +_MAX_PAGE_SIZE = 10000 # TEA 0.4.0 +_MAX_PAGE_SIZE_V1 = 100 # TEA 1.0.0 ``pageSize`` maximum -def _validate_page_size(page_size: int) -> None: +def _validate_page_size(page_size: int, max_size: int = _MAX_PAGE_SIZE) -> None: """Validate that page_size is within acceptable bounds.""" - if page_size < 1 or page_size > _MAX_PAGE_SIZE: - raise TeaValidationError(f"page_size must be between 1 and {_MAX_PAGE_SIZE}, got {page_size}") - - -def _validate_page_offset(page_offset: int) -> None: - """Validate that page_offset is non-negative.""" - if page_offset < 0: - raise TeaValidationError(f"page_offset must be >= 0, got {page_offset}") + if page_size < 1 or page_size > max_size: + raise TeaValidationError(f"page_size must be between 1 and {max_size}, got {page_size}") def _validate_collection_version(version: int) -> None: diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 1cedc29..637567b 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -464,13 +464,13 @@ def discover( @app.command("search-products") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_products( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -491,20 +491,20 @@ def search_products( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_products(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_products(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @app.command("search-releases") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_releases( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -525,7 +525,7 @@ def search_releases( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_product_releases(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_product_releases(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @@ -634,12 +634,12 @@ def get_collection( @app.command("get-product-releases") @click.argument("uuid") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def get_product_releases( uuid: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -660,7 +660,7 @@ def get_product_releases( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.get_product_releases(uuid, page_offset=page_offset, page_size=page_size) + result = client.get_product_releases(uuid, page_token=page_token, page_size=page_size) _output(result) @@ -797,13 +797,13 @@ def get_cle( @app.command("search-components") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_components( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -824,20 +824,20 @@ def search_components( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_components(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_components(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @app.command("search-component-releases") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_component_releases( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -858,7 +858,7 @@ def search_component_releases( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_component_releases(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_component_releases(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) diff --git a/src/libtea/client.py b/src/libtea/client.py index d3610fc..317acb5 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -21,11 +21,11 @@ from libtea._hashing import WEAK_HASH_ALGORITHMS from libtea._http import TeaHttpClient, probe_endpoint from libtea._validation import ( + _MAX_PAGE_SIZE_V1, _validate, _validate_artifact_version, _validate_collection_version, _validate_list, - _validate_page_offset, _validate_page_size, _validate_path_segment, ) @@ -35,6 +35,7 @@ TeaConnectionError, TeaDiscoveryError, TeaServerError, + TeaValidationError, ) from libtea.models import ( CLE, @@ -44,6 +45,7 @@ Component, ComponentReleaseWithCollection, DiscoveryInfo, + PaginatedCollectionResponse, PaginatedComponentReleaseResponse, PaginatedComponentResponse, PaginatedProductReleaseResponse, @@ -51,6 +53,7 @@ Product, ProductRelease, Release, + _PaginatedResponse, ) logger = logging.getLogger("libtea") @@ -233,45 +236,65 @@ def discover(self, tei: str) -> list[DiscoveryInfo]: data = self._http.get_json("/discovery", params={"tei": tei}) return _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) + # --- Pagination parameters --- + + def _page_params(self, page_token: str | None, page_size: int) -> dict[str, typing.Any]: + """Build the paging query: an opaque ``pageToken`` on TEA 1.x, an integer ``pageOffset`` on 0.x.""" + if not self.is_legacy: + _validate_page_size(page_size, _MAX_PAGE_SIZE_V1) + return {"pageSize": page_size} if page_token is None else {"pageSize": page_size, "pageToken": page_token} + _validate_page_size(page_size) + if page_token is not None and not (page_token.isascii() and page_token.isdigit()): + raise TeaValidationError( + f"page_token for a TEA 0.x server must be a non-negative integer offset, got {page_token!r}" + ) + return {"pageOffset": int(page_token or 0), "pageSize": page_size} + + def _all_pages(self, path: str, model_cls: type[_PaginatedResponse]) -> list[typing.Any]: + """Fetch every page of a TEA 1.x list endpoint that 0.4.0 served as a bare array.""" + + def fetch_page(*, page_token: str | None, page_size: int) -> _PaginatedResponse: + data = self._http.get_json(path, params=self._page_params(page_token, page_size)) + return _validate(model_cls, data, legacy=self.is_legacy) + + # ponytail: drains every page into memory; expose an iterator if lists get large + return list(self._paginate(fetch_page, _MAX_PAGE_SIZE_V1)) + # --- Products --- - def list_products(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductResponse: + def list_products(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedProductResponse: """List all products (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing products. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/products", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) def search_products( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductResponse: """Search for products by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching products. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/products", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) @@ -288,65 +311,61 @@ def get_product(self, uuid: str) -> Product: return _validate(Product, data, legacy=self.is_legacy) def get_product_releases( - self, uuid: str, *, page_offset: int = 0, page_size: int = 100 + self, uuid: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: """Get paginated releases for a product. Args: uuid: Product UUID. - page_offset: Zero-based page offset. - page_size: Number of results per page. + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( f"/product/{_validate_path_segment(uuid)}/releases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) # --- Product Releases --- - def list_product_releases(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductReleaseResponse: + def list_product_releases( + self, *, page_token: str | None = None, page_size: int = 100 + ) -> PaginatedProductReleaseResponse: """List all product releases (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/productReleases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) def search_product_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: """Search for product releases by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/productReleases", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) @@ -383,8 +402,10 @@ def get_product_release_collections(self, uuid: str) -> list[Collection]: Returns: List of all collection versions. """ - data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data, legacy=self.is_legacy) + path = f"/productRelease/{_validate_path_segment(uuid)}/collections" + if not self.is_legacy: + return self._all_pages(path, PaginatedCollectionResponse) + return _validate_list(Collection, self._http.get_json(path), legacy=self.is_legacy) def get_product_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a product release. @@ -417,7 +438,7 @@ def get_component(self, uuid: str) -> Component: def get_component_releases(self, uuid: str) -> list[Release]: """Get all releases for a component. - Unlike product releases, component releases are not paginated. + TEA 1.x paginates this endpoint and every page is fetched; TEA 0.4.0 returns a bare array. Args: uuid: Component UUID. @@ -425,46 +446,44 @@ def get_component_releases(self, uuid: str) -> list[Release]: Returns: List of component releases. """ - data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}/releases") - return _validate_list(Release, data, legacy=self.is_legacy) + path = f"/component/{_validate_path_segment(uuid)}/releases" + if not self.is_legacy: + return self._all_pages(path, PaginatedComponentReleaseResponse) + return _validate_list(Release, self._http.get_json(path), legacy=self.is_legacy) - def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedComponentResponse: + def list_components(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedComponentResponse: """List all components (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing components. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/components", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) def search_components( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentResponse: """Search for components by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching components. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/components", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) @@ -503,8 +522,10 @@ def get_component_release_collections(self, uuid: str) -> list[Collection]: Returns: List of all collection versions. """ - data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data, legacy=self.is_legacy) + path = f"/componentRelease/{_validate_path_segment(uuid)}/collections" + if not self.is_legacy: + return self._all_pages(path, PaginatedCollectionResponse) + return _validate_list(Collection, self._http.get_json(path), legacy=self.is_legacy) def get_component_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a component release. @@ -521,44 +542,40 @@ def get_component_release_collection(self, uuid: str, version: int) -> Collectio return _validate(Collection, data, legacy=self.is_legacy) def list_component_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: """List all component releases (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing component releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/componentReleases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) def search_component_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: """Search for component releases by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching component releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/componentReleases", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) @@ -731,15 +748,16 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: @staticmethod def _paginate(fetch_page: typing.Callable[..., typing.Any], page_size: int, **kwargs: typing.Any) -> Iterator[_T]: - """Generic paginator: calls *fetch_page* with page_offset/page_size, yields results.""" - _validate_page_size(page_size) - offset = 0 + """Generic paginator: calls *fetch_page* with page_token/page_size, following ``nextPageToken``.""" + token: str | None = None while True: - page = fetch_page(**kwargs, page_offset=offset, page_size=page_size) + page = fetch_page(**kwargs, page_token=token, page_size=page_size) yield from page.results - offset += len(page.results) - if offset >= page.total_results or not page.results: - break + if not page.has_next or not page.results: + return + if page.next_page_token == token: + raise TeaServerError(f"Server repeated pageToken {token!r}; stopping pagination") + token = page.next_page_token def iter_products(self, id_type: str, id_value: str, *, page_size: int = 100) -> Iterator[Product]: """Iterate over all products matching an identifier, auto-paginating. diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index 1c08b03..7e96ae5 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -124,7 +124,7 @@ def check_list_products(client: TeaClient, ctx: CheckContext) -> CheckResult: first = resp.results[0] ctx.product_uuid = ctx.product_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Listed {resp.total_results} product(s)") + return _pass(name, f"Listed {len(resp.results)} product(s)") def check_search_products(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -145,9 +145,9 @@ def check_search_products(client: TeaClient, ctx: CheckContext) -> CheckResult: resp = client.search_products(ident.id_type, ident.id_value) except TeaError as exc: return _fail(name, f"search_products() failed: {exc}", details=str(exc)) - if resp.total_results < 1: + if not resp.results: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {resp.total_results} product(s)") + return _pass(name, f"Search returned {len(resp.results)} product(s)") def check_get_product(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -189,7 +189,7 @@ def check_product_releases(client: TeaClient, ctx: CheckContext) -> CheckResult: first = resp.results[0] ctx.product_release_uuid = ctx.product_release_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Got {resp.total_results} release(s)") + return _pass(name, f"Got {len(resp.results)} release(s)") # --------------------------------------------------------------------------- @@ -209,7 +209,7 @@ def check_list_product_releases(client: TeaClient, ctx: CheckContext) -> CheckRe first = resp.results[0] ctx.product_release_uuid = ctx.product_release_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Listed {resp.total_results} product release(s)") + return _pass(name, f"Listed {len(resp.results)} product release(s)") def check_search_product_releases(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -230,9 +230,9 @@ def check_search_product_releases(client: TeaClient, ctx: CheckContext) -> Check resp = client.search_product_releases(ident.id_type, ident.id_value) except TeaError as exc: return _fail(name, f"search_product_releases() failed: {exc}", details=str(exc)) - if resp.total_results < 1: + if not resp.results: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {resp.total_results} release(s)") + return _pass(name, f"Search returned {len(resp.results)} release(s)") def check_get_product_release(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -485,7 +485,7 @@ def check_uuid_format(client: TeaClient, ctx: CheckContext) -> CheckResult: def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult: - """Verify that the /products listing exposes expected pagination metadata fields.""" + """Verify that the /products listing honours ``pageSize`` (and, on TEA 0.x, exposes offset metadata).""" name = "pagination_fields" try: result = client.list_products(page_size=10) @@ -493,14 +493,18 @@ def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult return _fail(name, f"list_products() failed: {exc}", details=str(exc)) issues: list[str] = [] - if result.page_size != 10: - issues.append(f"page_size: requested 10, got {result.page_size!r}") - if result.page_start_index < 0: - issues.append(f"page_start_index is negative: {result.page_start_index}") - if result.total_results < 0: - issues.append(f"total_results is negative: {result.total_results}") - if not result.timestamp: - issues.append("timestamp is empty") + if client.is_legacy: + if result.page_size != 10: + issues.append(f"page_size: requested 10, got {result.page_size!r}") + if result.page_start_index is None or result.page_start_index < 0: + issues.append(f"page_start_index is missing or negative: {result.page_start_index!r}") + if result.total_results is None or result.total_results < 0: + issues.append(f"total_results is missing or negative: {result.total_results!r}") + if not result.timestamp: + issues.append("timestamp is missing") + elif len(result.results) > 10: + # TEA 1.x pages carry no totals; hasNext/nextPageToken consistency is enforced by the model. + issues.append(f"page_size: requested 10, got {len(result.results)} results") if issues: return _fail(name, "Pagination metadata issues", details="; ".join(issues)) return _pass(name, "Pagination metadata fields present and valid") diff --git a/src/libtea/models.py b/src/libtea/models.py index e1326ab..192b79c 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -561,46 +561,73 @@ class CLE(_TeaModel): # --- Pagination --- -class PaginatedProductResponse(_TeaModel): +class _PaginatedResponse(_TeaModel): + """Common fields of a paginated TEA response. + + TEA 1.0.0 pages carry ``has_next`` and an opaque ``next_page_token``. 0.4.0 + pages carry offset metadata instead; under the legacy context ``has_next`` + and ``next_page_token`` are derived from it, the token being the next + result offset as a string. + """ + + has_next: bool + next_page_token: str | None = None + # TEA 0.4.0 only. + timestamp: datetime | None = None + page_start_index: int | None = None + page_size: int | None = None + total_results: int | None = None + + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if self.has_next and not self.next_page_token: + problems.append("hasNext is true but nextPageToken is missing") + return problems + + @model_validator(mode="before") + @classmethod + def _synthesize_legacy_token(cls, data: Any, info: ValidationInfo) -> Any: + """Derive ``hasNext``/``nextPageToken`` from 0.4.0 offsets (legacy context only).""" + if not (_is_legacy(info) and isinstance(data, dict) and "totalResults" in data): + return data + start, total, results = data.get("pageStartIndex"), data["totalResults"], data.get("results", []) + if not (isinstance(start, int) and isinstance(total, int) and isinstance(results, list)): + return data # malformed offsets: leave hasNext missing so validation reports it + next_offset = start + len(results) + has_next = bool(results) and next_offset < total + return {**data, "hasNext": has_next, **({"nextPageToken": str(next_offset)} if has_next else {})} + + +class PaginatedProductResponse(_PaginatedResponse): """Paginated response containing a list of products.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int results: tuple[Product, ...] = () -class PaginatedProductReleaseResponse(_TeaModel): +class PaginatedProductReleaseResponse(_PaginatedResponse): """Paginated response containing a list of product releases.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int results: tuple[ProductRelease, ...] = () -class PaginatedComponentResponse(_TeaModel): +class PaginatedComponentResponse(_PaginatedResponse): """Paginated response containing a list of components.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int results: tuple[Component, ...] = () -class PaginatedComponentReleaseResponse(_TeaModel): +class PaginatedComponentReleaseResponse(_PaginatedResponse): """Paginated response containing a list of component releases.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int results: tuple[Release, ...] = () +class PaginatedCollectionResponse(_PaginatedResponse): + """Paginated response containing a list of collections (TEA 1.0.0).""" + + results: tuple[Collection, ...] = () + + # --- Discovery types --- @@ -673,6 +700,7 @@ class DiscoveryInfo(_TeaModel): "DiscoveryInfo", "ErrorResponse", "Identifier", + "PaginatedCollectionResponse", "PaginatedComponentReleaseResponse", "PaginatedComponentResponse", "PaginatedProductReleaseResponse", diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index 4b71f5e..aac995b 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -51,26 +51,26 @@ def discover(self, tei: str) -> list[DiscoveryInfo]: ... # --- Products --- - def list_products(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductResponse: ... + def list_products(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedProductResponse: ... def search_products( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductResponse: ... def get_product(self, uuid: str) -> Product: ... def get_product_releases( - self, uuid: str, *, page_offset: int = 0, page_size: int = 100 + self, uuid: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... # --- Product Releases --- def list_product_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... def search_product_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... def get_product_release(self, uuid: str) -> ProductRelease: ... @@ -87,10 +87,10 @@ def get_component(self, uuid: str) -> Component: ... def get_component_releases(self, uuid: str) -> list[Release]: ... - def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedComponentResponse: ... + def list_components(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedComponentResponse: ... def search_components( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentResponse: ... # --- Component Releases --- @@ -104,11 +104,11 @@ def get_component_release_collections(self, uuid: str) -> list[Collection]: ... def get_component_release_collection(self, uuid: str, version: int) -> Collection: ... def list_component_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: ... def search_component_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: ... # --- CLE --- diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index d887d26..7f3f4b6 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1167,7 +1167,7 @@ def test_get_product_releases_with_pagination(self): ) result = runner.invoke( app, - ["--json", "get-product-releases", uuid, "--page-offset", "10", "--page-size", "5", "--base-url", BASE_URL], + ["--json", "get-product-releases", uuid, "--page-token", "10", "--page-size", "5", "--base-url", BASE_URL], ) assert result.exit_code == 0 data = json.loads(result.output) diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py index 0b6beef..a052d0f 100644 --- a/tests/cli/test_cli_fmt.py +++ b/tests/cli/test_cli_fmt.py @@ -57,7 +57,7 @@ ReleaseDistribution, TeaServerInfo, ) -from tests.spec_data import LEGACY # noqa: E402 +from tests.spec_data import LEGACY, product # noqa: E402 def _legacy(model_cls, **fields): @@ -134,6 +134,7 @@ def test_renders_pagination_and_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[Product(uuid=UUID, name="Test Product", identifiers=[])], ) @@ -146,12 +147,40 @@ def test_empty_results(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=0, results=[], ) output = _capture(fmt_search_products, data) assert "No results (total: 0)" in output + def test_v1_page_shows_count_and_next_token(self): + data = PaginatedProductResponse( + has_next=True, + next_page_token="t2", + results=[Product(uuid=UUID, name="Test Product", identifiers=[])], + ) + output = _capture(fmt_search_products, data) + assert "1 result(s)" in output + assert "More results: --page-token t2" in output + + def test_v1_last_page_has_no_next_hint(self): + data = PaginatedProductResponse(has_next=False, results=[]) + output = _capture(fmt_search_products, data) + assert "0 result(s)" in output + assert "--page-token" not in output + + def test_legacy_page_keeps_offset_range(self): + data = _legacy( + PaginatedProductResponse, + timestamp="2024-01-01T00:00:00Z", + pageStartIndex=0, + pageSize=2, + totalResults=3, + results=[product(UUID, "P1"), product(UUID2, "P2")], + ) + assert "Results 1-2 of 3" in _capture(fmt_search_products, data) + class TestFmtSearchReleases: def test_renders_table(self): @@ -160,6 +189,7 @@ def test_renders_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[ _legacy( @@ -1040,6 +1070,7 @@ def test_renders_pagination_and_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[Component(uuid=UUID, name="Test Component", identifiers=[])], ) @@ -1052,6 +1083,7 @@ def test_empty_results(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=0, results=[], ) @@ -1066,6 +1098,7 @@ def test_renders_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[ _legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", pre_release=True) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 845d242..05c2c4d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -63,7 +63,7 @@ def test_search_products_pagination(self, client, base_url): "results": [], }, ) - resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25) + resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -130,7 +130,7 @@ def test_list_products_with_pagination(self, client, base_url): "results": [], }, ) - resp = client.list_products(page_offset=10, page_size=25) + resp = client.list_products(page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -142,7 +142,7 @@ def test_list_products_rejects_invalid_page_size(self, client): def test_list_products_rejects_negative_offset(self, client): with pytest.raises(TeaValidationError): - client.list_products(page_offset=-1) + client.list_products(page_token="-1") class TestSearchProductReleases: @@ -212,7 +212,7 @@ def test_list_product_releases_with_pagination(self, client, base_url): "results": [], }, ) - client.list_product_releases(page_offset=5, page_size=50) + client.list_product_releases(page_token="5", page_size=50) request = responses.calls[0].request assert "pageOffset=5" in str(request.url) assert "pageSize=50" in str(request.url) @@ -223,7 +223,7 @@ def test_list_product_releases_invalid_page_size(self, client): def test_list_product_releases_invalid_page_offset(self, client): with pytest.raises(TeaValidationError): - client.list_product_releases(page_offset=-1) + client.list_product_releases(page_token="-1") class TestSearchComponents: @@ -265,7 +265,7 @@ def test_search_components_pagination(self, client, base_url): "results": [], }, ) - resp = client.search_components("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25) + resp = client.search_components("CPE", "cpe:2.3:a:vendor:product", page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -332,7 +332,7 @@ def test_list_components_with_pagination(self, client, base_url): "results": [], }, ) - resp = client.list_components(page_offset=10, page_size=25) + resp = client.list_components(page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -344,7 +344,7 @@ def test_list_components_rejects_invalid_page_size(self, client): def test_list_components_rejects_negative_offset(self, client): with pytest.raises(TeaValidationError): - client.list_components(page_offset=-1) + client.list_components(page_token="-1") class TestSearchComponentReleases: @@ -422,7 +422,7 @@ def test_list_component_releases_invalid_page_size(self, client): def test_list_component_releases_invalid_offset(self, client): with pytest.raises(TeaValidationError): - client.list_component_releases(page_offset=-1) + client.list_component_releases(page_token="-1") class TestProduct: @@ -1033,7 +1033,7 @@ def test_get_product_releases_pagination_params(self, client, base_url): "results": [], }, ) - resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_offset=50, page_size=25) + resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_token="50", page_size=25) request = responses.calls[0].request assert "pageOffset=50" in str(request.url) assert "pageSize=25" in str(request.url) diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index dcfa43c..efad57c 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -2,18 +2,24 @@ import pytest import responses +from responses import matchers from libtea._validation import _validate, _validate_list from libtea.client import TeaClient -from libtea.exceptions import TeaValidationError +from libtea.exceptions import TeaServerError, TeaValidationError from tests.spec_data import ( ARTIFACT, + COMPONENT, COMPONENT_RELEASE, LEGACY_BASE_URL, MD5_HEX, PRODUCT, + PRODUCT_RELEASE, V1_ARTIFACT, V1_BASE_URL, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, product, ) @@ -81,3 +87,136 @@ def test_v1_artifact_with_md5_rejected(): responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest", json=art) with pytest.raises(TeaValidationError, match="MD5"): TeaClient(V1_BASE_URL).get_artifact(ARTIFACT) + + +# --- Token pagination --- + + +@responses.activate +def test_first_page_omits_token(v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": False, "results": []}, + ) + assert v1.list_products().has_next is False + assert len(responses.calls) == 1 + + +@responses.activate +def test_page_token_sent(v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "tok-1"})], + json={"hasNext": False, "results": []}, + ) + v1.list_products(page_token="tok-1") + assert len(responses.calls) == 1 + + +def test_page_size_capped_at_100_on_v1(v1): + with pytest.raises(TeaValidationError, match="between 1 and 100"): + v1.list_products(page_size=101) + + +@responses.activate +def test_iter_follows_tokens(v1): + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher({"idType": "PURL", "idValue": "pkg:x", "pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "t2", "results": [V1_PRODUCT_RELEASE]}, + ) + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[ + matchers.query_param_matcher({"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", "pageToken": "t2"}) + ], + json={"hasNext": False, "results": [V1_PRODUCT_RELEASE]}, + ) + assert len(list(v1.iter_product_releases("PURL", "pkg:x"))) == 2 + + +@responses.activate +def test_iter_raises_on_repeated_token(v1): # Review Focus 1 + responses.get( + f"{V1_BASE_URL}/products", + json={"hasNext": True, "nextPageToken": "same", "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + + +@responses.activate +def test_iter_stops_on_empty_page(v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": []}) + assert list(v1.iter_products("PURL", "pkg:x")) == [] + assert len(responses.calls) == 1 + + +@responses.activate +def test_component_releases_collects_pages_on_v1(v1): + url = f"{V1_BASE_URL}/component/{COMPONENT}/releases" + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "n", "results": [V1_RELEASE]}, + ) + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "n"})], + json={"hasNext": False, "results": [V1_RELEASE]}, + ) + assert len(v1.get_component_releases(COMPONENT)) == 2 + + +@responses.activate +@pytest.mark.parametrize( + ("method", "path", "uuid"), + [ + ("get_product_release_collections", "productRelease", PRODUCT_RELEASE), + ("get_component_release_collections", "componentRelease", COMPONENT_RELEASE), + ], +) +def test_collections_collects_pages_on_v1(v1, method, path, uuid): + url = f"{V1_BASE_URL}/{path}/{uuid}/collections" + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "n", "results": [V1_COLLECTION]}, + ) + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "n"})], + json={"hasNext": False, "results": [V1_COLLECTION]}, + ) + assert len(getattr(v1, method)(uuid)) == 2 + + +@responses.activate +def test_legacy_token_maps_to_offset(legacy): + responses.get( + f"{LEGACY_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageOffset": "200", "pageSize": "100"})], + json={ + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": 200, + "pageSize": 100, + "totalResults": 200, + "results": [], + }, + ) + legacy.list_products(page_token="200") + assert len(responses.calls) == 1 + + +@responses.activate +@pytest.mark.parametrize("token", ["abc", "-1", "²", ""]) +def test_legacy_non_numeric_token_rejected(legacy, token): # Review Focus 2 + with pytest.raises(TeaValidationError, match="integer offset"): + legacy.list_products(page_token=token) + assert len(responses.calls) == 0 + + +def test_legacy_page_size_allows_10000(legacy): + with pytest.raises(TeaValidationError, match="between 1 and 10000"): + legacy.list_products(page_size=10001) diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index a9299d7..9fc7b46 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -34,6 +34,7 @@ check_uuid_format, ) from libtea.conformance._types import CheckStatus +from tests.spec_data import V1_BASE_URL BASE_URL = "https://api.example.com/tea/v0.4.0" @@ -1260,6 +1261,21 @@ def test_fail_on_page_size_mismatch(self): assert result.status == CheckStatus.FAIL assert "page_size" in result.details + @responses.activate + def test_v1_pass_within_page_size(self, v1): + responses.get( + f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": [_PRODUCT_JSON]} + ) + result = check_pagination_fields(v1, CheckContext()) + assert result.status == CheckStatus.PASS + + @responses.activate + def test_v1_fail_when_page_overflows(self, v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": False, "results": [_PRODUCT_JSON] * 11}) + result = check_pagination_fields(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "got 11 results" in result.details + class TestCheckCamelCaseFields: def test_pass_with_collected_uuids(self): diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index fc394df..4148826 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -22,6 +22,7 @@ ErrorType, Identifier, IdentifierType, + PaginatedCollectionResponse, PaginatedComponentReleaseResponse, PaginatedComponentResponse, PaginatedProductResponse, @@ -34,10 +35,12 @@ COMPONENT_RELEASE, LEGACY, MD5_HEX, + PRODUCT, V1_ARTIFACT, V1_COLLECTION, V1_PRODUCT_RELEASE, V1_RELEASE, + product, ) @@ -360,7 +363,7 @@ def test_paginated_product_response_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedProductResponse.model_validate(data) + resp = PaginatedProductResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () @@ -380,11 +383,60 @@ def test_paginated_product_response(self): } ], } - resp = PaginatedProductResponse.model_validate(data) + resp = PaginatedProductResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert resp.results[0].name == "Apache Log4j 2" +class TestPagination: + def test_v1_page(self): + p = PaginatedProductResponse.model_validate( + {"hasNext": True, "nextPageToken": "eyJwYWdlIjoiY29udGludWF0aW9uIn0", "results": []} + ) + assert (p.has_next, p.next_page_token, p.total_results) == (True, "eyJwYWdlIjoiY29udGludWF0aW9uIn0", None) + + def test_v1_has_next_without_token_rejected(self): + with pytest.raises(ValidationError, match="nextPageToken"): + PaginatedProductResponse.model_validate({"hasNext": True, "results": []}) + + def test_legacy_shape_rejected_on_v1(self): + with pytest.raises(ValidationError, match="hasNext"): # Pydantic reports missing fields by alias + PaginatedProductResponse.model_validate( + { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": 0, + "pageSize": 2, + "totalResults": 3, + "results": [], + } + ) + + @pytest.mark.parametrize( + ("start", "n", "total", "has_next", "token"), + [(0, 2, 3, True, "2"), (2, 1, 3, False, None), (0, 0, 5, False, None)], + ) + def test_legacy_offsets_synthesize_token(self, start, n, total, has_next, token): + data = { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": start, + "pageSize": 2, + "totalResults": total, + "results": [product(PRODUCT, f"P{i}") for i in range(n)], + } + p = PaginatedProductResponse.model_validate(data, context=LEGACY) + assert (p.has_next, p.next_page_token) == (has_next, token) + + def test_legacy_bad_offsets_rejected(self): + with pytest.raises(ValidationError): + PaginatedProductResponse.model_validate( + {"pageStartIndex": "x", "totalResults": 3, "results": []}, context=LEGACY + ) + + def test_collection_page(self): + p = PaginatedCollectionResponse.model_validate({"hasNext": False, "results": [V1_COLLECTION]}) + assert p.results[0].version == 2 + + class TestCLEEventType: @pytest.mark.parametrize( "value", @@ -675,7 +727,7 @@ def test_basic(self): } ], } - resp = PaginatedComponentResponse.model_validate(data) + resp = PaginatedComponentResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert isinstance(resp.results[0], Component) assert resp.results[0].name == "Test Component" @@ -688,7 +740,7 @@ def test_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedComponentResponse.model_validate(data) + resp = PaginatedComponentResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () @@ -721,7 +773,7 @@ def test_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedComponentReleaseResponse.model_validate(data) + resp = PaginatedComponentReleaseResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 7db9c8a..ae8c3b8 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -9,7 +9,6 @@ _validate_artifact_version, _validate_collection_version, _validate_list, - _validate_page_offset, _validate_page_size, _validate_path_segment, ) @@ -106,30 +105,20 @@ def test_search_product_releases_rejects_bad_page_size(self, client): client.search_product_releases("PURL", "pkg:pypi/foo", page_size=_MAX_PAGE_SIZE + 1) -class TestPageOffsetValidation: - """page_offset parameter is validated in search/paginated methods.""" - - def test_validate_page_offset_rejects_negative(self): - with pytest.raises(TeaValidationError, match="page_offset must be >= 0"): - _validate_page_offset(-1) - - def test_validate_page_offset_accepts_zero(self): - _validate_page_offset(0) # should not raise - - def test_validate_page_offset_accepts_positive(self): - _validate_page_offset(100) # should not raise +class TestPageTokenValidation: + """page_token must be an integer offset on TEA 0.x servers.""" def test_search_products_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.search_products("PURL", "pkg:pypi/foo", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.search_products("PURL", "pkg:pypi/foo", page_token="-1") def test_get_product_releases_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_token="-1") def test_search_product_releases_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.search_product_releases("PURL", "pkg:pypi/foo", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.search_product_releases("PURL", "pkg:pypi/foo", page_token="-1") class TestCollectionVersionValidation: From bbbc62e3aaf55b430c5ac2738e038502230bebd9 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:32:41 +0500 Subject: [PATCH 08/29] fix: bound token pagination and key CLI header on negotiated version --- src/libtea/_cli_fmt.py | 3 ++- src/libtea/client.py | 17 ++++++++++---- src/libtea/models.py | 41 ++++++++++++++++----------------- tests/cli/test_cli_fmt.py | 11 +++++++++ tests/client/test_client_v1.py | 42 ++++++++++++++++++++++++++++++++++ tests/client/test_iterators.py | 38 ++++++++++++++++++++++++++++++ tests/unit/test_models.py | 10 ++++++++ 7 files changed, 135 insertions(+), 27 deletions(-) diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index 3e2f16c..00ae086 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import shlex from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -84,7 +85,7 @@ def _pagination_header( if data.total_results is None: # TEA 1.x: no totals, only a continuation token console.print(Text(f"{len(data.results)} result(s)", style="dim")) if data.has_next: - console.print(Text(f"More results: --page-token {data.next_page_token}", style="dim")) + console.print(Text(f"More results: --page-token {shlex.quote(data.next_page_token or '')}", style="dim")) elif not data.results: console.print(Text(f"No results (total: {data.total_results})", style="dim")) else: diff --git a/src/libtea/client.py b/src/libtea/client.py index 317acb5..b600319 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -65,6 +65,10 @@ _T = TypeVar("_T") +# ponytail: hard ceiling of 10,000 pages per pagination run (1M results at pageSize 100) so a server +# minting endless distinct tokens cannot loop forever; make it a parameter if real catalogs get that big. +_MAX_PAGES = 10_000 + def _resolve_spec_version(base_url: str, explicit: str | None) -> Version: """Resolve the TEA spec version: explicit argument, then a ``/v`` URL suffix, then the default.""" @@ -250,10 +254,10 @@ def _page_params(self, page_token: str | None, page_size: int) -> dict[str, typi ) return {"pageOffset": int(page_token or 0), "pageSize": page_size} - def _all_pages(self, path: str, model_cls: type[_PaginatedResponse]) -> list[typing.Any]: + def _all_pages(self, path: str, model_cls: type[_PaginatedResponse[_T]]) -> list[_T]: """Fetch every page of a TEA 1.x list endpoint that 0.4.0 served as a bare array.""" - def fetch_page(*, page_token: str | None, page_size: int) -> _PaginatedResponse: + def fetch_page(*, page_token: str | None, page_size: int) -> _PaginatedResponse[_T]: data = self._http.get_json(path, params=self._page_params(page_token, page_size)) return _validate(model_cls, data, legacy=self.is_legacy) @@ -750,14 +754,17 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: def _paginate(fetch_page: typing.Callable[..., typing.Any], page_size: int, **kwargs: typing.Any) -> Iterator[_T]: """Generic paginator: calls *fetch_page* with page_token/page_size, following ``nextPageToken``.""" token: str | None = None - while True: + seen: set[str] = set() + for _ in range(_MAX_PAGES): page = fetch_page(**kwargs, page_token=token, page_size=page_size) yield from page.results if not page.has_next or not page.results: return - if page.next_page_token == token: - raise TeaServerError(f"Server repeated pageToken {token!r}; stopping pagination") token = page.next_page_token + if token in seen: + raise TeaServerError(f"Server repeated pageToken {token!r}; stopping pagination") + seen.add(token) + raise TeaServerError(f"Pagination exceeded {_MAX_PAGES} pages; stopping") def iter_products(self, id_type: str, id_value: str, *, page_size: int = 100) -> Iterator[Product]: """Iterate over all products matching an identifier, auto-paginating. diff --git a/src/libtea/models.py b/src/libtea/models.py index 192b79c..c7051de 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -11,7 +11,7 @@ from datetime import datetime from enum import StrEnum -from typing import Any, ClassVar, Literal, Self +from typing import Any, ClassVar, Generic, Literal, Self, TypeVar from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from pydantic.alias_generators import to_camel @@ -560,14 +560,18 @@ class CLE(_TeaModel): # --- Pagination --- +_R = TypeVar("_R") +_LEGACY_PAGE_KEYS = ("timestamp", "pageStartIndex", "pageSize", "totalResults") -class _PaginatedResponse(_TeaModel): + +class _PaginatedResponse(_TeaModel, Generic[_R]): """Common fields of a paginated TEA response. TEA 1.0.0 pages carry ``has_next`` and an opaque ``next_page_token``. 0.4.0 pages carry offset metadata instead; under the legacy context ``has_next`` and ``next_page_token`` are derived from it, the token being the next - result offset as a string. + result offset as a string. Outside the legacy context the offset metadata + is dropped, so ``total_results is None`` exactly on TEA 1.x pages. """ has_next: bool @@ -577,6 +581,7 @@ class _PaginatedResponse(_TeaModel): page_start_index: int | None = None page_size: int | None = None total_results: int | None = None + results: tuple[_R, ...] = () def _v1_problems(self) -> list[str]: problems = super()._v1_problems() @@ -586,9 +591,13 @@ def _v1_problems(self) -> list[str]: @model_validator(mode="before") @classmethod - def _synthesize_legacy_token(cls, data: Any, info: ValidationInfo) -> Any: - """Derive ``hasNext``/``nextPageToken`` from 0.4.0 offsets (legacy context only).""" - if not (_is_legacy(info) and isinstance(data, dict) and "totalResults" in data): + def _map_legacy_offsets(cls, data: Any, info: ValidationInfo) -> Any: + """Derive ``hasNext``/``nextPageToken`` from 0.4.0 offsets; drop the offsets outside the legacy context.""" + if not isinstance(data, dict): + return data + if not _is_legacy(info): + return {k: v for k, v in data.items() if k not in _LEGACY_PAGE_KEYS} + if "totalResults" not in data: return data start, total, results = data.get("pageStartIndex"), data["totalResults"], data.get("results", []) if not (isinstance(start, int) and isinstance(total, int) and isinstance(results, list)): @@ -598,35 +607,25 @@ def _synthesize_legacy_token(cls, data: Any, info: ValidationInfo) -> Any: return {**data, "hasNext": has_next, **({"nextPageToken": str(next_offset)} if has_next else {})} -class PaginatedProductResponse(_PaginatedResponse): +class PaginatedProductResponse(_PaginatedResponse[Product]): """Paginated response containing a list of products.""" - results: tuple[Product, ...] = () - -class PaginatedProductReleaseResponse(_PaginatedResponse): +class PaginatedProductReleaseResponse(_PaginatedResponse[ProductRelease]): """Paginated response containing a list of product releases.""" - results: tuple[ProductRelease, ...] = () - -class PaginatedComponentResponse(_PaginatedResponse): +class PaginatedComponentResponse(_PaginatedResponse[Component]): """Paginated response containing a list of components.""" - results: tuple[Component, ...] = () - -class PaginatedComponentReleaseResponse(_PaginatedResponse): +class PaginatedComponentReleaseResponse(_PaginatedResponse[Release]): """Paginated response containing a list of component releases.""" - results: tuple[Release, ...] = () - -class PaginatedCollectionResponse(_PaginatedResponse): +class PaginatedCollectionResponse(_PaginatedResponse[Collection]): """Paginated response containing a list of collections (TEA 1.0.0).""" - results: tuple[Collection, ...] = () - # --- Discovery types --- diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py index a052d0f..53bc7c7 100644 --- a/tests/cli/test_cli_fmt.py +++ b/tests/cli/test_cli_fmt.py @@ -164,6 +164,17 @@ def test_v1_page_shows_count_and_next_token(self): assert "1 result(s)" in output assert "More results: --page-token t2" in output + def test_v1_page_with_totals_still_shows_next_token(self): + data = PaginatedProductResponse.model_validate( + {"hasNext": True, "nextPageToken": "t2", "totalResults": 5, "results": [product(UUID, "P1")]} + ) + assert data.total_results is None + assert "More results: --page-token t2" in _capture(fmt_search_products, data) + + def test_v1_next_token_is_shell_quoted(self): + data = PaginatedProductResponse(has_next=True, next_page_token="a b", results=[]) + assert "More results: --page-token 'a b'" in _capture(fmt_search_products, data) + def test_v1_last_page_has_no_next_hint(self): data = PaginatedProductResponse(has_next=False, results=[]) output = _capture(fmt_search_products, data) diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index efad57c..0e20521 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -146,6 +146,37 @@ def test_iter_raises_on_repeated_token(v1): # Review Focus 1 list(v1.iter_products("PURL", "pkg:x")) +@responses.activate +def test_iter_raises_on_token_cycle(v1): + url = f"{V1_BASE_URL}/products" + for sent, nxt in [(None, "A"), ("A", "B"), ("B", "A")]: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get( + url, + match=[matchers.query_param_matcher(query)], + json={"hasNext": True, "nextPageToken": nxt, "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 3 + + +@responses.activate +def test_iter_stops_at_page_cap(v1, monkeypatch): + monkeypatch.setattr("libtea.client._MAX_PAGES", 2) + url = f"{V1_BASE_URL}/products" + for sent, nxt in [(None, "t1"), ("t1", "t2")]: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get( + url, + match=[matchers.query_param_matcher(query)], + json={"hasNext": True, "nextPageToken": nxt, "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="Pagination exceeded 2 pages"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 2 + + @responses.activate def test_iter_stops_on_empty_page(v1): responses.get(f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": []}) @@ -220,3 +251,14 @@ def test_legacy_non_numeric_token_rejected(legacy, token): # Review Focus 2 def test_legacy_page_size_allows_10000(legacy): with pytest.raises(TeaValidationError, match="between 1 and 10000"): legacy.list_products(page_size=10001) + + +@responses.activate +def test_legacy_page_size_10000_accepted(legacy): + responses.get( + f"{LEGACY_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageOffset": "0", "pageSize": "10000"})], + json={"timestamp": "2024-03-20T15:30:00Z", "pageStartIndex": 0, "pageSize": 10000, "totalResults": 0}, + ) + legacy.list_products(page_size=10000) + assert len(responses.calls) == 1 diff --git a/tests/client/test_iterators.py b/tests/client/test_iterators.py index 8f4bbc8..0c74d31 100644 --- a/tests/client/test_iterators.py +++ b/tests/client/test_iterators.py @@ -2,8 +2,10 @@ import pytest import responses +from responses import matchers from libtea.client import TeaClient +from libtea.exceptions import TeaServerError from libtea.models import Component, Product, ProductRelease, Release BASE_URL = "https://api.example.com/tea/v0.4.0" @@ -22,6 +24,42 @@ def _release_json(uuid: str, version: str) -> dict: } +def _legacy_page(start: int, total: int, names: list[str]) -> dict: + return { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": start, + "pageSize": 2, + "totalResults": total, + "results": [_product_json("a1b2c3d4-e5f6-7890-abcd-ef1234567890", n) for n in names], + } + + +class TestLegacyOffsetTokens: + @responses.activate + def test_second_page_requested_at_next_offset(self): + query = {"idType": "PURL", "idValue": "pkg:pypi/foo", "pageSize": "2"} + responses.get( + f"{BASE_URL}/products", + match=[matchers.query_param_matcher({**query, "pageOffset": "0"})], + json=_legacy_page(0, 3, ["P1", "P2"]), + ) + responses.get( + f"{BASE_URL}/products", + match=[matchers.query_param_matcher({**query, "pageOffset": "2"})], + json=_legacy_page(2, 3, ["P3"]), + ) + with TeaClient(BASE_URL) as client: + names = [p.name for p in client.iter_products("PURL", "pkg:pypi/foo", page_size=2)] + assert names == ["P1", "P2", "P3"] + assert len(responses.calls) == 2 + + @responses.activate + def test_server_ignoring_offset_raises(self): + responses.get(f"{BASE_URL}/products", json=_legacy_page(0, 3, ["P1", "P2"])) + with TeaClient(BASE_URL) as client, pytest.raises(TeaServerError, match="repeated"): + list(client.iter_products("PURL", "pkg:pypi/foo", page_size=2)) + + class TestIterProducts: @responses.activate def test_iter_products_single_page(self): diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 4148826..6943269 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -395,6 +395,16 @@ def test_v1_page(self): ) assert (p.has_next, p.next_page_token, p.total_results) == (True, "eyJwYWdlIjoiY29udGludWF0aW9uIn0", None) + def test_v1_drops_legacy_offsets(self): + p = PaginatedProductResponse.model_validate( + {"hasNext": False, "totalResults": 5, "pageStartIndex": 0, "pageSize": 2, "timestamp": "x", "results": []} + ) + assert (p.total_results, p.page_start_index, p.page_size, p.timestamp) == (None, None, None, None) + + def test_non_object_rejected(self): + with pytest.raises(ValidationError): + PaginatedProductResponse.model_validate([]) + def test_v1_has_next_without_token_rejected(self): with pytest.raises(ValidationError, match="nextPageToken"): PaginatedProductResponse.model_validate({"hasNext": True, "results": []}) From efe54a75ae5a5ee30d8da9dcd8cadb5ff7af495a Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:39:59 +0500 Subject: [PATCH 09/29] feat: parse TEA 1.0.0 flattened release-with-collection responses --- src/libtea/__init__.py | 5 ++++ src/libtea/client.py | 8 +++--- src/libtea/models.py | 37 ++++++++++++++++++++++++-- src/libtea/protocols.py | 3 ++- tests/cli/test_cli_fmt.py | 7 +++++ tests/client/test_client_v1.py | 35 +++++++++++++++++++++++++ tests/unit/test_models.py | 48 ++++++++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/libtea/__init__.py b/src/libtea/__init__.py index 964243f..7e56d72 100644 --- a/src/libtea/__init__.py +++ b/src/libtea/__init__.py @@ -159,6 +159,9 @@ from libtea.models import ( ProductRelease as ProductRelease, ) + from libtea.models import ( + ProductReleaseWithCollection as ProductReleaseWithCollection, + ) from libtea.models import ( Release as Release, ) @@ -235,6 +238,7 @@ "PaginatedProductResponse", "Product", "ProductRelease", + "ProductReleaseWithCollection", "Release", "ReleaseDistribution", "TeaEndpoint", @@ -298,6 +302,7 @@ "PaginatedProductResponse": "libtea.models", "Product": "libtea.models", "ProductRelease": "libtea.models", + "ProductReleaseWithCollection": "libtea.models", "Release": "libtea.models", "ReleaseDistribution": "libtea.models", "TeaEndpoint": "libtea.models", diff --git a/src/libtea/client.py b/src/libtea/client.py index b600319..41271c8 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -52,6 +52,7 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, _PaginatedResponse, ) @@ -373,17 +374,18 @@ def search_product_releases( ) return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) - def get_product_release(self, uuid: str) -> ProductRelease: + def get_product_release(self, uuid: str) -> ProductReleaseWithCollection: """Get a product release by UUID. Args: uuid: Product release UUID. Returns: - The product release with its component references. + The product release with its component references and, on TEA 1.x + servers, its latest collection. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}") - return _validate(ProductRelease, data, legacy=self.is_legacy) + return _validate(ProductReleaseWithCollection, data, legacy=self.is_legacy) def get_product_release_collection_latest(self, uuid: str) -> Collection: """Get the latest collection for a product release. diff --git a/src/libtea/models.py b/src/libtea/models.py index c7051de..724fbc6 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -415,12 +415,29 @@ class Release(_TeaModel): class ComponentReleaseWithCollection(_TeaModel): """A component release bundled with its latest collection. - Returned by ``GET /componentRelease/{uuid}``. + Returned by ``GET /componentRelease/{uuid}``. TEA 1.0.0 flattens the release + fields next to ``latestCollection``; TEA 0.4.0 nests them under ``release``. + Both parse into the same ``release`` / ``latest_collection`` shape. """ release: Release latest_collection: Collection + @model_validator(mode="before") + @classmethod + def _unflatten(cls, data: Any, info: ValidationInfo) -> Any: + """Wrap the 1.0.0 flattened wire object into ``release`` + ``latestCollection``. + + Skipped in the legacy context (already nested) and for Python-side + construction from a ``Release`` instance. + """ + if _is_legacy(info) or not isinstance(data, dict) or isinstance(data.get("release"), Release): + return data + return { + "release": {k: v for k, v in data.items() if k != "latestCollection"}, + "latestCollection": data.get("latestCollection"), + } + class Product(_TeaModel): """A TEA product (optional grouping of components).""" @@ -448,7 +465,7 @@ class ProductRelease(_TeaModel): components: References to the components included in this product release. """ - _v1_required = ("product",) + _v1_required: ClassVar[tuple[str, ...]] = ("product",) # widened so subclasses can extend it uuid: str product: str | None = None @@ -461,6 +478,21 @@ class ProductRelease(_TeaModel): components: tuple[ComponentRef, ...] +class ProductReleaseWithCollection(ProductRelease): + """A product release bundled with its latest collection. + + Returned by ``GET /productRelease/{uuid}``. TEA 1.0.0 requires + ``latest_collection``; 0.4.0 servers omit it. + + Attributes: + latest_collection: The latest collection for this product release. + """ + + _v1_required = ProductRelease._v1_required + ("latest_collection",) + + latest_collection: Collection | None = None + + # --- CLE (Common Lifecycle Enumeration) --- @@ -706,6 +738,7 @@ class DiscoveryInfo(_TeaModel): "PaginatedProductResponse", "Product", "ProductRelease", + "ProductReleaseWithCollection", "Release", "ReleaseDistribution", "TeaEndpoint", diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index aac995b..81e5240 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -25,6 +25,7 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, ) @@ -73,7 +74,7 @@ def search_product_releases( self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... - def get_product_release(self, uuid: str) -> ProductRelease: ... + def get_product_release(self, uuid: str) -> ProductReleaseWithCollection: ... def get_product_release_collection_latest(self, uuid: str) -> Collection: ... diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py index 53bc7c7..a154da8 100644 --- a/tests/cli/test_cli_fmt.py +++ b/tests/cli/test_cli_fmt.py @@ -53,6 +53,7 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, ReleaseDistribution, TeaServerInfo, @@ -532,6 +533,12 @@ def test_dispatch_product(self): output = _capture(format_output, product) assert "Product" in output + def test_dispatch_product_release_with_collection(self): + data = _legacy( + ProductReleaseWithCollection, uuid=UUID, version="1.0.0", created_date="2024-01-01", components=[] + ) + assert "Product Release" in _capture(format_output, data) + def test_dispatch_discover_via_command(self): data = [ DiscoveryInfo( diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 0e20521..08b5c77 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -262,3 +262,38 @@ def test_legacy_page_size_10000_accepted(legacy): ) legacy.list_products(page_size=10000) assert len(responses.calls) == 1 + + +@responses.activate +def test_get_product_release_v1(v1): + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**V1_PRODUCT_RELEASE, "latestCollection": {**V1_COLLECTION, "belongsTo": "PRODUCT_RELEASE"}}, + ) + assert v1.get_product_release(PRODUCT_RELEASE).latest_collection.belongs_to == "PRODUCT_RELEASE" + + +@responses.activate +def test_get_product_release_v1_without_collection_rejected(v1): + responses.get(f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", json=V1_PRODUCT_RELEASE) + with pytest.raises(TeaValidationError, match="latest_collection"): + v1.get_product_release(PRODUCT_RELEASE) + + +@responses.activate +def test_get_component_release_v1_flattened(v1): + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={**V1_RELEASE, "latestCollection": V1_COLLECTION}, + ) + result = v1.get_component_release(COMPONENT_RELEASE) + assert (result.release.component, result.latest_collection.version) == (COMPONENT, 2) + + +@responses.activate +def test_get_component_release_legacy_nested(legacy): + responses.get( + f"{LEGACY_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={"release": V1_RELEASE, "latestCollection": V1_COLLECTION}, + ) + assert legacy.get_component_release(COMPONENT_RELEASE).release.uuid == COMPONENT_RELEASE diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6943269..6f3a6c3 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -18,6 +18,7 @@ CollectionUpdateReasonType, ComplianceDocumentType, Component, + ComponentReleaseWithCollection, ErrorResponse, ErrorType, Identifier, @@ -28,6 +29,7 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, TeiType, ) @@ -854,3 +856,49 @@ def test_md5_accepted_legacy(self): def test_fedramp_value(self): assert ComplianceDocumentType.FEDRAMP == "FEDRAMP" + + +class TestReleaseWithCollection: + def test_component_release_flattened_on_v1(self): + r = ComponentReleaseWithCollection.model_validate({**V1_RELEASE, "latestCollection": V1_COLLECTION}) + assert (r.release.version, r.latest_collection.version) == ("2.24.3", 2) + + def test_component_release_nested_rejected_on_v1(self): + with pytest.raises(ValidationError): + ComponentReleaseWithCollection.model_validate({"release": V1_RELEASE, "latestCollection": V1_COLLECTION}) + + def test_component_release_nested_legacy(self): + r = ComponentReleaseWithCollection.model_validate( + {"release": V1_RELEASE, "latestCollection": V1_COLLECTION}, context=LEGACY + ) + assert r.release.uuid == COMPONENT_RELEASE + + def test_component_release_requires_latest_collection_on_v1(self): + with pytest.raises(ValidationError, match="latestCollection"): + ComponentReleaseWithCollection.model_validate(V1_RELEASE) + + def test_component_release_built_from_instances(self): + built = ComponentReleaseWithCollection( + release=Release.model_validate(V1_RELEASE), latest_collection=Collection.model_validate(V1_COLLECTION) + ) + assert built.latest_collection.version == 2 + + def test_product_release_requires_latest_collection_on_v1(self): + with pytest.raises(ValidationError, match="latest_collection"): + ProductReleaseWithCollection.model_validate(V1_PRODUCT_RELEASE) + + def test_product_release_with_collection_on_v1(self): + r = ProductReleaseWithCollection.model_validate({**V1_PRODUCT_RELEASE, "latestCollection": V1_COLLECTION}) + assert isinstance(r, ProductRelease) + assert (r.product, r.latest_collection.version) == (V1_PRODUCT_RELEASE["product"], 2) + + def test_product_release_still_requires_product_on_v1(self): + data = {k: v for k, v in V1_PRODUCT_RELEASE.items() if k != "product"} + with pytest.raises(ValidationError, match="'product'"): + ProductReleaseWithCollection.model_validate({**data, "latestCollection": V1_COLLECTION}) + + def test_product_release_without_collection_legacy(self): + r = ProductReleaseWithCollection.model_validate( + {k: v for k, v in V1_PRODUCT_RELEASE.items() if k != "product"}, context=LEGACY + ) + assert r.latest_collection is None From 8cb33dcfc15b9305227698393bc7a2928f4c9561 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:46:16 +0500 Subject: [PATCH 10/29] feat: parse TEA 1.0.0 tei:// identifiers alongside urn:tei --- src/libtea/cli.py | 4 ++-- src/libtea/client.py | 10 +++++--- src/libtea/discovery.py | 41 +++++++++++++++++++++++++++---- tests/cli/test_cli.py | 26 ++++++++++++++++++++ tests/client/test_client_v1.py | 33 ++++++++++++++++++++++++- tests/client/test_discovery.py | 44 ++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 637567b..201df5e 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -181,9 +181,9 @@ def _parse_basic_auth(auth: str | None) -> tuple[str, str] | None: def _domain_from_tei(tei: str | None) -> str | None: - """Extract the domain component from a TEI URN for auto-discovery. + """Extract the domain component from a TEI (``tei://`` or ``urn:tei:``) for auto-discovery. - Returns ``None`` if ``tei`` is falsy or not a valid TEI URN. + Returns ``None`` if ``tei`` is falsy or not a valid TEI. """ if not tei: return None diff --git a/src/libtea/client.py b/src/libtea/client.py index 41271c8..0d3df1c 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -228,18 +228,22 @@ def discover(self, tei: str) -> list[DiscoveryInfo]: """Resolve a TEI to product release UUID(s) via the discovery endpoint. Args: - tei: TEI URN string (e.g. ``urn:tei:purl:example.com:pkg:pypi/lib@1.0``). + tei: TEI string, sent verbatim (e.g. ``tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1`` + or ``urn:tei:purl:example.com:pkg:pypi/lib@1.0``). Returns: List of discovery results, each with a product release UUID and servers. Raises: - TeaValidationError: If the response is malformed. + TeaValidationError: If the response is malformed, or empty on a TEA 1.x server. TeaConnectionError: On network failure. """ # requests auto-encodes query params — do NOT pre-encode with quote() data = self._http.get_json("/discovery", params={"tei": tei}) - return _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) + results = _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) + if not results and not self.is_legacy: + raise TeaValidationError("Invalid discovery response: expected a non-empty array") + return results # --- Pagination parameters --- diff --git a/src/libtea/discovery.py b/src/libtea/discovery.py index 7f1dab0..4f818a9 100644 --- a/src/libtea/discovery.py +++ b/src/libtea/discovery.py @@ -1,11 +1,12 @@ """TEI parsing, .well-known/tea fetching, and SemVer-based endpoint selection. -Implements the TEA discovery flow: parse a TEI URN, fetch the ``.well-known/tea`` +Implements the TEA discovery flow: parse a TEI, fetch the ``.well-known/tea`` document from the TEI's domain, and select the best-matching endpoint using SemVer 2.0.0 comparison and priority-based ordering. """ import logging +import re import warnings from dataclasses import dataclass from typing import Any @@ -24,6 +25,12 @@ _VALID_TEI_TYPES = frozenset(e.value for e in TeiType) +_TEI_URI_PREFIX = "tei://" +_TEI_URI_TYPE = re.compile(r"[a-z][a-z0-9]*") +_TEI_EXPECTED_FORMAT = ( + "Expected format: tei://// (or legacy urn:tei:::)" +) + def _endpoint_sort_key(pair: tuple[_SemVer, TeaEndpoint]) -> tuple[_SemVer, float]: """Sort key: highest SemVer version desc, then priority desc (default 1.0 per TEA spec).""" @@ -51,13 +58,35 @@ def _is_valid_domain(domain: str) -> bool: return True +def _parse_tei_uri(tei: str) -> tuple[str, str, str]: + """Parse ``tei:////``; the type is checked syntactically, not against ``TeiType``.""" + parts = tei[len(_TEI_URI_PREFIX) :].split("/") + if len(parts) == 3: + domain, tei_type, identifier = parts + # _is_valid_domain also rejects ":" (no port allowed) + if ( + _is_valid_domain(domain) + and _TEI_URI_TYPE.fullmatch(tei_type) + and identifier + and not {"?", "#"} & set(identifier) + and "%2f" not in identifier.lower() + ): + return tei_type, domain, identifier + raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. {_TEI_EXPECTED_FORMAT}") + + def parse_tei(tei: str) -> tuple[str, str, str]: - """Parse a TEI URN into (type, domain, identifier). + """Parse a TEI into (type, domain, identifier). + + Two forms are accepted: - TEI format: ``urn:tei:::`` + - ``tei:////`` (TEA 1.0.0). The scheme is case-sensitive, the domain is + returned unchanged, any lowercase alphanumeric ``type`` is accepted (the type registry may grow), + and the identifier may contain ``:`` but not ``/``, ``%2F``, ``?`` or ``#``. + - ``urn:tei:::`` (TEA 0.4.0), where ``type`` must be a known :class:`TeiType`. Args: - tei: TEI URN string. + tei: TEI string. Returns: Tuple of (type, domain, identifier). @@ -65,9 +94,11 @@ def parse_tei(tei: str) -> tuple[str, str, str]: Raises: TeaDiscoveryError: If the TEI format is invalid. """ + if tei.startswith(_TEI_URI_PREFIX): + return _parse_tei_uri(tei) parts = tei.split(":") if len(parts) < 5 or parts[0] != "urn" or parts[1] != "tei": - raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. Expected format: urn:tei:::") + raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. {_TEI_EXPECTED_FORMAT}") tei_type = parts[2] if tei_type not in _VALID_TEI_TYPES: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 7f3f4b6..9e56c2a 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -6,6 +6,7 @@ import pytest import requests import responses +from responses import matchers click = pytest.importorskip("click", reason="click not installed (install libtea[cli])") @@ -819,6 +820,31 @@ def test_discover_auto_extracts_domain_from_tei(self): data = json.loads(result.output) assert len(data) == 1 + @responses.activate + def test_discover_auto_extracts_domain_from_tei_uri(self): + tei = "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + responses.get( + "https://example.com/.well-known/tea", + json={ + "schemaVersion": 1, + "endpoints": [{"url": "https://api.example.com", "versions": ["0.4.0"]}], + }, + ) + responses.head("https://api.example.com/v0.4.0", status=200) + responses.get( + "https://api.example.com/v0.4.0/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[ + { + "productReleaseUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}], + } + ], + ) + result = runner.invoke(app, ["--json", "discover", tei]) + assert result.exit_code == 0 + assert len(json.loads(result.output)) == 1 + class TestCLIEntryPointErrors: """Test _cli_entry.py error handling.""" diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 08b5c77..509cc58 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -62,12 +62,43 @@ def test_validate(self, request, fixture, base_url, expected): @pytest.mark.parametrize("fixture, base_url, expected", CASES) def test_validate_list(self, request, fixture, base_url, expected): client = request.getfixturevalue(fixture) - responses.get(f"{base_url}/discovery", json=[]) + responses.get( + f"{base_url}/discovery", + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) with patch("libtea.client._validate_list", wraps=_validate_list) as spy: client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") assert spy.call_args.kwargs == {"legacy": expected} +@responses.activate +def test_discover_passes_tei_verbatim(v1): + tei = "tei://Example.com/uuid/x" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + assert v1.discover(tei)[0].product_release_uuid == PRODUCT_RELEASE + + +@responses.activate +def test_discover_empty_array_rejected_on_v1(v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + with pytest.raises(TeaValidationError, match="non-empty"): + v1.discover("tei://example.com/uuid/x") + + @responses.activate def test_v1_client_rejects_incomplete_collection(): responses.get( diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index 7f08fdb..535b57d 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -94,6 +94,50 @@ def test_tei_with_slash_in_purl_identifier(self): assert identifier == "pkg:maven/org.apache/log4j@2.24.3" +class TestParseTeiUri: + def test_uuid_tei(self): + assert parse_tei("tei://products.example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == ( + "uuid", + "products.example.com", + "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + ) + + def test_hash_identifier_keeps_colon(self): + tei = "tei://example.com/hash/SHA-256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9" + assert parse_tei(tei)[2].startswith("SHA-256:") + + def test_unregistered_type_accepted(self): + assert parse_tei("tei://example.com/newtype2/x")[0] == "newtype2" + + def test_domain_not_rewritten(self): + assert parse_tei("tei://Example.com/uuid/x")[1] == "Example.com" + + @pytest.mark.parametrize( + "bad", + [ + "tei://example.com:8443/uuid/x", + "tei://example.com/uuid/a/b", + "tei://example.com/uuid/a%2Fb", + "tei://example.com/uuid/a%2fb", + "tei://example.com/uuid/", + "tei://example.com/UUID/x", + "tei://example.com/1uuid/x", + "tei://example.com/uuid/x?y=1", + "tei://example.com/uuid/x#frag", + "tei://example.com/uuid", + "tei:///uuid/x", + "tei://bad_domain.com/uuid/x", + "TEI://example.com/uuid/x", + ], + ) + def test_rejects_malformed(self, bad): # Review Focus 5 + with pytest.raises(TeaDiscoveryError, match="tei:////"): + parse_tei(bad) + + def test_urn_form_still_parsed(self): + assert parse_tei("urn:tei:uuid:example.com:abc") == ("uuid", "example.com", "abc") + + class TestFetchWellKnown: @responses.activate def test_fetch_well_known_success(self): From f755d1f147c11e086a6595b43a623efcfdc22d51 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:50:11 +0500 Subject: [PATCH 11/29] fix: CLI download recognises tei:// sources --- src/libtea/cli.py | 10 ++++--- tests/cli/test_cli.py | 68 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 201df5e..33f1dbe 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -38,6 +38,8 @@ logger = logging.getLogger("libtea") +_TEI_PREFIXES = ("tei://", "urn:tei:") + def _configure_logging(*, verbose: bool, debug: bool) -> None: """Set up logging based on --verbose / --debug flags.""" @@ -1115,9 +1117,9 @@ def download( port: int | None, allow_private_ips: bool, ) -> None: - """Fetch artifact(s) from a URL or TEI URN. + """Fetch artifact(s) from a URL or TEI. - SOURCE is a direct artifact URL or a TEI URN (urn:tei:...). + SOURCE is a direct artifact URL or a TEI (tei://... or urn:tei:...). \b URL mode: download @@ -1136,7 +1138,7 @@ def download( if output_file: raise click.UsageError("--output is not supported by 'download'; use DESTINATION to specify the output path") - if source.startswith("urn:tei:"): + if source.startswith(_TEI_PREFIXES): if checksum: raise click.UsageError("--checksum is not supported in TEI mode (checksums come from server metadata)") if destination is not None and Path(destination).is_file(): @@ -1162,7 +1164,7 @@ def download( # URL mode: existing direct download behavior if dry_run: - raise click.UsageError("--dry-run is only supported in TEI mode (urn:tei:... source)") + raise click.UsageError("--dry-run is only supported in TEI mode (tei://... or urn:tei:... source)") if destination is None: raise click.UsageError("DESTINATION is required when downloading from a URL") dest_path = Path(destination) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 9e56c2a..5e458c8 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -2245,6 +2245,74 @@ def test_dry_run_shows_would_download(self, tmp_path): assert "sbom.cdx.json" in combined assert not dest.exists() + @responses.activate + def test_tei_uri_dry_run_enters_tei_mode(self, tmp_path): + """A tei:// source is TEI mode: --dry-run lists artifacts instead of being rejected as URL mode.""" + tei = "tei://example.com/uuid/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + responses.get( + f"{BASE_URL}/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["1.0.0"]}]}], + ) + responses.get( + f"{BASE_URL}/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": "https://cdn.example.com/sbom.cdx.json", "checksums": []}], + }, + ], + }, + ) + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--dry-run", "--base-url", BASE_URL]) + assert result.exit_code == 0 + assert "Would download:" in _all_output(result) + assert not dest.exists() + + @responses.activate + def test_tei_uri_download_reaches_discovery(self, tmp_path): + """Without --base-url, a tei:// source auto-discovers via its domain's .well-known/tea and downloads.""" + tei = "tei://example.com/uuid/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + artifact_url = "https://cdn.example.com/sbom.cdx.json" + responses.get( + "https://example.com/.well-known/tea", + json={"schemaVersion": 1, "endpoints": [{"url": "https://api.example.com", "versions": ["0.4.0"]}]}, + ) + responses.head("https://api.example.com/v0.4.0", status=200) + responses.get( + "https://api.example.com/v0.4.0/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["1.0.0"]}]}], + ) + responses.get( + f"https://api.example.com/v0.4.0/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, + ], + }, + ) + responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}') + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest)]) + assert result.exit_code == 0, _all_output(result) + assert (dest / "sbom.cdx.json").exists() + def test_dry_run_rejected_in_url_mode(self): """--dry-run is only valid in TEI mode.""" result = runner.invoke( From f331b62bcbb125a43430cdfa93a989bae3b67586 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 19:55:57 +0500 Subject: [PATCH 12/29] feat: negotiate TEA 1.0.0 then 0.4.0, keep advertised version spelling --- src/libtea/client.py | 42 +++++++++++++--- src/libtea/discovery.py | 21 +++++++- tests/client/test_discovery.py | 91 ++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/libtea/client.py b/src/libtea/client.py index 0d3df1c..79317a2 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -29,7 +29,7 @@ _validate_page_size, _validate_path_segment, ) -from libtea.discovery import fetch_well_known, select_best_endpoint, select_endpoints +from libtea.discovery import _advertised_version, fetch_well_known, select_best_endpoint, select_endpoints from libtea.exceptions import ( TeaChecksumError, TeaConnectionError, @@ -152,7 +152,7 @@ def from_well_known( token: str | None = None, basic_auth: tuple[str, str] | None = None, timeout: float = 30.0, - version: str = "0.4.0", + version: str | None = None, scheme: str = "https", port: int | None = None, max_retries: int = 3, @@ -166,13 +166,17 @@ def from_well_known( with the requested ``version`` (SemVer match), and probes each in priority order. If an endpoint is unreachable or returns a server error, the next candidate is tried (per TEA spec: "MUST retry ... with the next endpoint"). + The API path uses each endpoint's version string exactly as advertised + (build metadata included, e.g. ``/v1.0.0+build.5``). Args: domain: Domain name to resolve (e.g. ``tea.example.com``). token: Optional bearer token. basic_auth: Optional ``(username, password)`` tuple. timeout: Request timeout in seconds (default 30). - version: TEA spec SemVer to match against (default ``0.4.0``). + version: TEA spec SemVer to match against. The default (``None``) negotiates + the highest of :data:`SUPPORTED_SPEC_VERSIONS` the server advertises: + ``1.0.0`` first, then ``0.4.0``. scheme: URL scheme for discovery — ``"https"`` (default) or ``"http"``. port: Optional port for ``.well-known`` resolution. max_retries: Retry count on 5xx (default 3). @@ -187,17 +191,38 @@ def from_well_known( Raises: TeaDiscoveryError: If no compatible or reachable endpoint is found - (wraps the last probe failure as ``__cause__``). + (wraps the last probe failure as ``__cause__``), or the negotiated + version is not one this client supports. """ well_known = fetch_well_known(domain, timeout=timeout, scheme=scheme, port=port) - best = select_best_endpoint(well_known, version) + if version is not None: + best = select_best_endpoint(well_known, version) + else: + for wanted in SUPPORTED_SPEC_VERSIONS: + try: + best = select_best_endpoint(well_known, wanted) + break + except TeaDiscoveryError: + continue + else: + offered = sorted({v for ep in well_known.endpoints for v in ep.versions}) + raise TeaDiscoveryError( + f"No compatible endpoint found for any version tried {list(SUPPORTED_SPEC_VERSIONS)}. " + f"Available versions: {offered}" + ) matched_version = best.matched_version + try: + _resolve_spec_version(best.endpoint.url, matched_version) + except ValueError as exc: + raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc # Get all endpoints that advertise the matched version (exact match) for failover candidates = select_endpoints(well_known, matched_version) errors: list[tuple[str, Exception]] = [] for endpoint in candidates: - base_url = f"{endpoint.url.rstrip('/')}/v{matched_version}" + # Build metadata does not affect precedence, so each endpoint may spell the version differently. + advertised = _advertised_version(endpoint, matched_version) + base_url = f"{endpoint.url.rstrip('/')}/v{advertised}" try: probe_endpoint(base_url, timeout=min(timeout, 5.0)) except (TeaConnectionError, TeaServerError) as exc: @@ -213,14 +238,15 @@ def from_well_known( backoff_factor=backoff_factor, allow_private_ips=allow_private_ips, cache_ttl=cache_ttl, + spec_version=advertised, ) if errors: summary = "; ".join(f"{url}: {exc}" for url, exc in errors) raise TeaDiscoveryError( - f"All {len(errors)} endpoint(s) failed for version {version!r}: {summary}" + f"All {len(errors)} endpoint(s) failed for version {matched_version!r}: {summary}" ) from errors[-1][1] - raise TeaDiscoveryError(f"No reachable endpoint found for version {version!r}") # pragma: no cover + raise TeaDiscoveryError(f"No reachable endpoint found for version {matched_version!r}") # pragma: no cover # --- Discovery --- diff --git a/src/libtea/discovery.py b/src/libtea/discovery.py index 4f818a9..663c820 100644 --- a/src/libtea/discovery.py +++ b/src/libtea/discovery.py @@ -308,13 +308,30 @@ class VersionedEndpoint: """An endpoint paired with the server version it was matched on. Returned by :func:`select_best_endpoint` to let callers know which - version to use in the URL path (``v{matched_version}``). + version to use in the URL path (``v{matched_version}``). ``matched_version`` + is the string exactly as the endpoint advertises it, build metadata included. """ endpoint: TeaEndpoint matched_version: str +def _advertised_version(ep: TeaEndpoint, version: str) -> str: + """Return the spelling ``ep`` advertises for ``version``. + + SemVer equality ignores build metadata, so ``1.0.0`` equals ``1.0.0+build.5``; the spec requires + the path to use the string exactly as advertised, so recover it from the endpoint. + """ + target = _SemVer.parse(version) + for v_str in ep.versions: + try: + if _SemVer.parse(v_str) == target: + return v_str + except ValueError: + continue + return version # pragma: no cover — callers only pass a version the endpoint advertises + + def _is_compatible_version(client: _SemVer, server: _SemVer) -> bool: """Check if a client version is backward-compatible with a server version. @@ -358,7 +375,7 @@ def select_best_endpoint(well_known: TeaWellKnown, supported_version: str) -> Ve # Phase 1: exact match — delegate to select_endpoints try: exact = select_endpoints(well_known, supported_version) - return VersionedEndpoint(endpoint=exact[0], matched_version=supported_version) + return VersionedEndpoint(endpoint=exact[0], matched_version=_advertised_version(exact[0], supported_version)) except TeaDiscoveryError: pass # fall through to Phase 2 (compatible match) diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index 535b57d..8b7b7be 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -3,6 +3,7 @@ import responses from pydantic import ValidationError +from libtea.client import TeaClient from libtea.discovery import ( VersionedEndpoint, _is_compatible_version, @@ -806,3 +807,93 @@ def test_negotiation_emits_warning(self): warnings.simplefilter("always") select_best_endpoint(wk, "0.4.0") assert any("negotiated to server version" in str(warning.message) for warning in w) + + +class TestEndpointNegotiation: + """``TeaClient.from_well_known`` negotiates 1.0.0, then 0.4.0, and keeps the advertised spelling.""" + + API = "https://api.example.com" + + def _discover(self, *endpoints: dict, **kwargs) -> TeaClient: + responses.get("https://example.com/.well-known/tea", json={"schemaVersion": 1, "endpoints": list(endpoints)}) + return TeaClient.from_well_known("example.com", **kwargs) + + @responses.activate + def test_negotiates_highest_supported(self): + responses.head(f"{self.API}/v1.0.0", status=200) + client = self._discover({"url": self.API, "versions": ["0.4.0", "1.0.0"]}) + assert client.spec_version == "1.0.0" + assert client.is_legacy is False + assert responses.calls[1].request.url == f"{self.API}/v1.0.0" + + @responses.activate + def test_falls_back_to_0_4_0(self): + responses.head(f"{self.API}/v0.4.0", status=200) + client = self._discover({"url": self.API, "versions": ["0.4.0"]}) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert responses.calls[1].request.url == f"{self.API}/v0.4.0" + + @responses.activate + def test_build_metadata_spelling_used(self): + responses.head(f"{self.API}/v1.0.0+build.5", status=200) + responses.get( + f"{self.API}/v1.0.0+build.5/discovery", + json=[ + { + "productReleaseUuid": "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + "servers": [{"rootUrl": self.API, "versions": ["1.0.0+build.5"]}], + } + ], + ) + client = self._discover({"url": self.API, "versions": ["1.0.0+build.5"]}) + assert client.spec_version == "1.0.0+build.5" + assert responses.calls[1].request.url == f"{self.API}/v1.0.0+build.5" + client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") + assert responses.calls[2].request.url.startswith(f"{self.API}/v1.0.0+build.5/discovery?tei=") + + @responses.activate + def test_each_failover_candidate_uses_its_own_spelling(self): + responses.head("https://a.example.com/v1.0.0+a", status=500) + responses.head("https://b.example.com/v1.0.0+b", status=200) + client = self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0+a"], "priority": 1.0}, + {"url": "https://b.example.com", "versions": ["1.0.0+b"], "priority": 0.5}, + ) + assert client.spec_version == "1.0.0+b" + assert [c.request.url for c in responses.calls[1:]] == [ + "https://a.example.com/v1.0.0+a", + "https://b.example.com/v1.0.0+b", + ] + + @responses.activate + def test_explicit_version_kept(self): + responses.head(f"{self.API}/v0.4.0", status=200) + client = self._discover({"url": self.API, "versions": ["1.0.0", "0.4.0"]}, version="0.4.0") + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert responses.calls[1].request.url == f"{self.API}/v0.4.0" + + @responses.activate + def test_unsupported_explicit_version_raises_discovery_error_without_probing(self): + with pytest.raises(TeaDiscoveryError, match="Unsupported TEA spec version") as exc_info: + self._discover({"url": self.API, "versions": ["2.0.0"]}, version="2.0.0") + assert isinstance(exc_info.value.__cause__, ValueError) + assert len(responses.calls) == 1 # only .well-known/tea was fetched + + @responses.activate + def test_no_supported_version_lists_tried_and_offered(self): + with pytest.raises(TeaDiscoveryError) as exc_info: + self._discover({"url": self.API, "versions": ["2.0.0", "0.9.0-x"]}) + message = str(exc_info.value) + assert "'1.0.0', '0.4.0'" in message + assert "'2.0.0'" in message + + def test_select_best_endpoint_returns_advertised_spelling(self): + wk = TeaWellKnown( + schema_version=1, + endpoints=[TeaEndpoint(url="https://api.example.com", versions=["not-semver", "1.0.0+build.5"])], + ) + assert select_best_endpoint(wk, "1.0.0").matched_version == "1.0.0+build.5" + with pytest.warns(UserWarning, match="negotiated to server version"): + assert select_best_endpoint(wk, "1.1.0").matched_version == "1.0.0+build.5" From 42adf3bde4847bc0ce6af1e30064124cfdb49b04 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:00:45 +0500 Subject: [PATCH 13/29] fix: fail over across supported TEA versions during discovery --- src/libtea/client.py | 64 +++++++++++++++++----------------- tests/client/test_discovery.py | 43 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/libtea/client.py b/src/libtea/client.py index 79317a2..1924041 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -168,6 +168,8 @@ def from_well_known( candidate is tried (per TEA spec: "MUST retry ... with the next endpoint"). The API path uses each endpoint's version string exactly as advertised (build metadata included, e.g. ``/v1.0.0+build.5``). + With the default ``version=None`` the candidates span every supported version, + highest first, so failover can also fall back from ``1.0.0`` to ``0.4.0``. Args: domain: Domain name to resolve (e.g. ``tea.example.com``). @@ -195,34 +197,36 @@ def from_well_known( version is not one this client supports. """ well_known = fetch_well_known(domain, timeout=timeout, scheme=scheme, port=port) - if version is not None: - best = select_best_endpoint(well_known, version) - else: - for wanted in SUPPORTED_SPEC_VERSIONS: - try: - best = select_best_endpoint(well_known, wanted) - break - except TeaDiscoveryError: - continue - else: - offered = sorted({v for ep in well_known.endpoints for v in ep.versions}) - raise TeaDiscoveryError( - f"No compatible endpoint found for any version tried {list(SUPPORTED_SPEC_VERSIONS)}. " - f"Available versions: {offered}" - ) - matched_version = best.matched_version - try: - _resolve_spec_version(best.endpoint.url, matched_version) - except ValueError as exc: - raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc - # Get all endpoints that advertise the matched version (exact match) for failover - candidates = select_endpoints(well_known, matched_version) + # Ordered (endpoint URL, advertised version) attempts: highest version first, then endpoint priority. + # Without an explicit ``version`` every supported version contributes, so failover can cross versions. + wanted_versions = (version,) if version is not None else SUPPORTED_SPEC_VERSIONS + attempts: dict[tuple[str, str], None] = {} # insertion-ordered set + for wanted in wanted_versions: + try: + best = select_best_endpoint(well_known, wanted) + except TeaDiscoveryError: + if version is not None: + raise + continue + matched_version = best.matched_version + try: + # Rejects an unsupported version (possible with an explicit ``version``) before any probe. + _resolve_spec_version(best.endpoint.url, matched_version) + except ValueError as exc: + raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc + for endpoint in select_endpoints(well_known, matched_version): + # Build metadata does not affect precedence, so each endpoint may spell the version differently. + attempts[(endpoint.url, _advertised_version(endpoint, matched_version))] = None + if not attempts: + offered = sorted({v for ep in well_known.endpoints for v in ep.versions}) + raise TeaDiscoveryError( + f"No compatible endpoint found for any version tried {list(SUPPORTED_SPEC_VERSIONS)}. " + f"Available versions: {offered}" + ) errors: list[tuple[str, Exception]] = [] - for endpoint in candidates: - # Build metadata does not affect precedence, so each endpoint may spell the version differently. - advertised = _advertised_version(endpoint, matched_version) - base_url = f"{endpoint.url.rstrip('/')}/v{advertised}" + for url, advertised in attempts: + base_url = f"{url.rstrip('/')}/v{advertised}" try: probe_endpoint(base_url, timeout=min(timeout, 5.0)) except (TeaConnectionError, TeaServerError) as exc: @@ -241,12 +245,8 @@ def from_well_known( spec_version=advertised, ) - if errors: - summary = "; ".join(f"{url}: {exc}" for url, exc in errors) - raise TeaDiscoveryError( - f"All {len(errors)} endpoint(s) failed for version {matched_version!r}: {summary}" - ) from errors[-1][1] - raise TeaDiscoveryError(f"No reachable endpoint found for version {matched_version!r}") # pragma: no cover + summary = "; ".join(f"{url}: {exc}" for url, exc in errors) + raise TeaDiscoveryError(f"All {len(errors)} endpoint(s) failed: {summary}") from errors[-1][1] # --- Discovery --- diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index 8b7b7be..4097271 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -866,6 +866,43 @@ def test_each_failover_candidate_uses_its_own_spelling(self): "https://b.example.com/v1.0.0+b", ] + @responses.activate + def test_fails_over_across_supported_versions(self): + responses.head("https://a.example.com/v1.0.0", status=500) + responses.head("https://b.example.com/v0.4.0", status=200) + client = self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0"]}, + {"url": "https://b.example.com", "versions": ["0.4.0"]}, + ) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [ + "https://a.example.com/v1.0.0", + "https://b.example.com/v0.4.0", + ] + + @responses.activate + def test_higher_version_beats_higher_priority(self): + responses.head("https://new.example.com/v1.0.0", status=200) + client = self._discover( + {"url": "https://old.example.com", "versions": ["0.4.0"], "priority": 1.0}, + {"url": "https://new.example.com", "versions": ["1.0.0"], "priority": 0.1}, + ) + assert client.spec_version == "1.0.0" + assert [c.request.url for c in responses.calls[1:]] == ["https://new.example.com/v1.0.0"] + + @responses.activate + def test_all_attempts_fail_lists_every_url_tried(self): + responses.head("https://a.example.com/v1.0.0", status=500) + responses.head("https://b.example.com/v0.4.0", body=requests.ConnectionError("refused")) + with pytest.raises(TeaDiscoveryError, match="All 2 endpoint") as exc_info: + self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0"]}, + {"url": "https://b.example.com", "versions": ["0.4.0"]}, + ) + assert "https://a.example.com/v1.0.0" in str(exc_info.value) + assert "https://b.example.com/v0.4.0" in str(exc_info.value) + @responses.activate def test_explicit_version_kept(self): responses.head(f"{self.API}/v0.4.0", status=200) @@ -874,6 +911,12 @@ def test_explicit_version_kept(self): assert client.is_legacy is True assert responses.calls[1].request.url == f"{self.API}/v0.4.0" + @responses.activate + def test_explicit_version_without_match_does_not_try_other_versions(self): + with pytest.raises(TeaDiscoveryError, match="No .*endpoint found for version '1.0.0'"): + self._discover({"url": self.API, "versions": ["0.4.0"]}, version="1.0.0") + assert len(responses.calls) == 1 + @responses.activate def test_unsupported_explicit_version_raises_discovery_error_without_probing(self): with pytest.raises(TeaDiscoveryError, match="Unsupported TEA spec version") as exc_info: From acf4ca61287de787489030ca0ae8d3a4d8cd138f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:03:59 +0500 Subject: [PATCH 14/29] feat: TEA 1.0.0 error types, tolerate unknown error values --- src/libtea/_http.py | 44 +++++++++++++++++++---------------- src/libtea/exceptions.py | 17 +++++++++++--- src/libtea/models.py | 25 +++++++++++++++----- tests/client/test_http.py | 48 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_models.py | 28 ++++++++++++++++++++--- 5 files changed, 130 insertions(+), 32 deletions(-) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index d3e56ea..a1f5471 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -38,6 +38,21 @@ _DOWNLOAD_CHUNK_SIZE = 8192 +def _tea_error_type(raw: bytes) -> str | None: + """Return the ``error`` value of a TEA error body, or ``None``. + + A TEA error is a JSON object with a string ``error`` property. Anything else + (empty, non-JSON, non-object, non-string ``error``) yields ``None``. Unknown + ``error`` values are returned as-is; clients must not reject them. + """ + try: + body = json.loads(raw) + except ValueError: # includes JSONDecodeError and UnicodeDecodeError; empty input + return None + error = body.get("error") if isinstance(body, dict) else None + return error if isinstance(error, str) else None + + def probe_endpoint(url: str, timeout: float = 5.0) -> None: """Probe a URL to verify the server is reachable. @@ -394,30 +409,19 @@ def _raise_for_status(response: requests.Response) -> None: if status in (401, 403): logger.warning("Authentication failed: HTTP %d for %s", status, response.url) raise TeaAuthenticationError(f"Authentication failed: HTTP {status}") - if status == 404: - error_type = None - try: - raw_bytes = TeaHttpClient._read_error_body(response) - if raw_bytes: - body = json.loads(raw_bytes) - if isinstance(body, dict): - error_type = body.get("error") - except (ValueError, UnicodeDecodeError): - pass - raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type) if status >= 500: raise TeaServerError(f"Server error: HTTP {status}") + # 4xx: read the bounded body once; the snippet and the TEA error type both come from it. + raw_bytes = TeaHttpClient._read_error_body(response) + error_type = _tea_error_type(raw_bytes) + if status == 404: + raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type) # Remaining 4xx codes (400, 405-499 excluding 401/403/404) limit = _ERROR_BODY_SNIPPET_BYTES - body_text = "" - try: - raw_bytes = TeaHttpClient._read_error_body(response) - body_text = raw_bytes.decode("utf-8", errors="replace")[:limit] - if len(raw_bytes) > limit: - body_text += " (truncated)" - except Exception: - pass + body_text = raw_bytes.decode("utf-8", errors="replace")[:limit] + if len(raw_bytes) > limit: + body_text += " (truncated)" msg = f"Client error: HTTP {status}" if body_text: msg = f"{msg} — {body_text}" - raise TeaRequestError(msg) + raise TeaRequestError(msg, error_type=error_type) diff --git a/src/libtea/exceptions.py b/src/libtea/exceptions.py index 8ac4564..4d08b15 100644 --- a/src/libtea/exceptions.py +++ b/src/libtea/exceptions.py @@ -26,8 +26,9 @@ class TeaNotFoundError(TeaError): """HTTP 404 response from the TEA server. Attributes: - error_type: Optional TEA error type from the JSON response body - (e.g. ``"OBJECT_UNKNOWN"`` or ``"OBJECT_NOT_SHAREABLE"``). + error_type: Optional TEA ``error`` value from the JSON response body + (e.g. ``"OBJECT_UNKNOWN"``; ``"OBJECT_NOT_SHAREABLE"`` on 0.4.0). + Servers may send values not in :class:`~libtea.models.ErrorType`. """ def __init__(self, message: str, *, error_type: str | None = None): @@ -36,7 +37,17 @@ def __init__(self, message: str, *, error_type: str | None = None): class TeaRequestError(TeaError): - """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404).""" + """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404). + + Attributes: + error_type: Optional TEA ``error`` value from the JSON response body + (e.g. ``"INVALID_PAGE_TOKEN"``). Servers may send values not in + :class:`~libtea.models.ErrorType`. + """ + + def __init__(self, message: str, *, error_type: str | None = None): + super().__init__(message) + self.error_type = error_type class TeaServerError(TeaError): diff --git a/src/libtea/models.py b/src/libtea/models.py index 724fbc6..b0aef92 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -186,20 +186,33 @@ class CollectionUpdateReasonType(StrEnum): class ErrorType(StrEnum): - """TEA API error types returned in 404 responses.""" + """TEA API error types carried in the ``error`` property of a 4xx response body. + + Servers may send values not listed here; clients must not reject them, so + :attr:`ErrorResponse.error` is a plain ``str``. ``OBJECT_NOT_SHAREABLE`` is + 0.4.0 only. + """ OBJECT_UNKNOWN = "OBJECT_UNKNOWN" - OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE" + OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE" # 0.4.0 only + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + NO_ACCEPTABLE_FORMAT = "NO_ACCEPTABLE_FORMAT" + SIGNATURE_NOT_FOUND = "SIGNATURE_NOT_FOUND" + INVALID_REQUEST = "INVALID_REQUEST" + INVALID_PAGE_TOKEN = "INVALID_PAGE_TOKEN" class ErrorResponse(_TeaModel): - """TEA API 404 error response body. + """TEA API error response body. - Per spec, 404 responses return ``{"error": "OBJECT_UNKNOWN"}`` or - ``{"error": "OBJECT_NOT_SHAREABLE"}`` with no additional fields. + ``error`` is typically one of :class:`ErrorType` (compare with ``==``) but + is kept as ``str``: the spec forbids rejecting a response over an + ``error`` value the client does not know. ``message`` is an optional + human-readable explanation for diagnostics only. """ - error: ErrorType + error: str + message: str | None = None # --- Shared types --- diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 7347e7a..2b5cf52 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -20,6 +20,7 @@ TeaServerError, TeaValidationError, ) +from tests.spec_data import LEGACY_BASE_URL class TestTeaHttpClient: @@ -314,6 +315,53 @@ def test_404_with_json_array_body(self, http_client, base_url): assert exc_info.value.error_type is None +class TestErrorTypeFromBody: + """Every 4xx surfaces the TEA ``error`` value when the body is a JSON object with a string ``error``.""" + + @responses.activate + def test_400_carries_error_type(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=400, json={"error": "INVALID_PAGE_TOKEN"}) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type == "INVALID_PAGE_TOKEN" + assert "INVALID_PAGE_TOKEN" in str(exc.value) + + @responses.activate + def test_unknown_error_value_passes_through(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=404, json={"error": "SOMETHING_NEW"}) + with pytest.raises(TeaNotFoundError) as exc: + http_client.get_json("/products") + assert exc.value.error_type == "SOMETHING_NEW" + + @responses.activate + def test_404_non_string_error_ignored(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=404, json={"error": 5}) + with pytest.raises(TeaNotFoundError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_400_non_string_error_ignored(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=400, json={"error": ["INVALID_REQUEST"]}) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_400_non_json_body_has_no_error_type(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body="Bad Request") + with pytest.raises(TeaRequestError, match="Bad Request") as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_3xx_has_no_error_type(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=301) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + BASE_URL = "https://api.example.com/tea/v0.4.0" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6f3a6c3..3adc98b 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -220,6 +220,17 @@ def test_error_type_values(self): assert ErrorType.OBJECT_NOT_SHAREABLE == "OBJECT_NOT_SHAREABLE" assert isinstance(ErrorType.OBJECT_UNKNOWN, str) + def test_error_type_1_0_0_members(self): + assert {e.value for e in ErrorType} == { + "OBJECT_UNKNOWN", + "OBJECT_NOT_SHAREABLE", + "NOT_IMPLEMENTED", + "NO_ACCEPTABLE_FORMAT", + "SIGNATURE_NOT_FOUND", + "INVALID_REQUEST", + "INVALID_PAGE_TOKEN", + } + class TestProduct: def test_product_from_json(self): @@ -706,12 +717,23 @@ def test_from_json_object_not_shareable(self): def test_to_json(self): resp = ErrorResponse(error=ErrorType.OBJECT_UNKNOWN) - data = resp.model_dump(by_alias=True) + data = resp.model_dump(by_alias=True, exclude_none=True) assert data == {"error": "OBJECT_UNKNOWN"} - def test_rejects_unknown_error_type(self): + def test_unknown_error_value_accepted(self): + resp = ErrorResponse.model_validate({"error": "SOMETHING_NEW", "message": "x"}) + assert resp.error == "SOMETHING_NEW" + assert resp.message == "x" + + def test_message_optional(self): + assert ErrorResponse.model_validate({"error": "INVALID_REQUEST"}).message is None + + def test_known_error_compares_equal_to_enum(self): + assert ErrorResponse.model_validate({"error": "INVALID_PAGE_TOKEN"}).error == ErrorType.INVALID_PAGE_TOKEN + + def test_non_string_error_rejected(self): with pytest.raises(ValidationError): - ErrorResponse.model_validate({"error": "INVALID_TYPE"}) + ErrorResponse.model_validate({"error": 5}) def test_extra_fields_tolerated(self): """_TeaModel uses extra='ignore' — tolerant parsing for robustness.""" From 8f38adbda010ac37fe6442cf784bb8394a184ecc Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:05:40 +0500 Subject: [PATCH 15/29] fix: parse TEA error type from a larger bounded read --- CLAUDE.md | 2 +- src/libtea/_http.py | 17 ++++++++++------- tests/client/test_http.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a4c010..d06d220 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ conformance/ Conformance test suite (optional dependency: pytest) - `_validation._validate()` wraps Pydantic `ValidationError` into `TeaValidationError` so all client errors are `TeaError` subclasses - Endpoint failover: `from_well_known()` probes candidates in priority order, skipping unreachable ones - `probe_endpoint()` lives in `_http.py` (not `client.py`) to maintain the HTTP layer boundary -- `_raise_for_status()` uses bounded reads (201 bytes) for error body snippets to avoid memory issues on streaming responses +- `_raise_for_status()` reads 4xx bodies once, bounded to 4 KiB, to parse the TEA `error` value (`_tea_error_type`); the message snippet is cut to 200 characters. This avoids memory issues on streaming responses - CLI formatters in `_cli_fmt.py` escape all server-controlled strings with `rich.markup.escape()` to prevent Rich markup injection - Conformance checks run sequentially; earlier checks populate `CheckContext` with discovered UUIDs for later checks - `from __future__ import annotations` is safe in conformance modules (no Pydantic models, only dataclasses) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index a1f5471..b9169ba 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -35,6 +35,8 @@ _MAX_DOWNLOAD_REDIRECTS = 10 _ERROR_BODY_SNIPPET_BYTES = 200 +# A TEA error body may carry a long `message`; read enough to parse it so `error` is not lost to the snippet cut. +_ERROR_BODY_PARSE_BYTES = 4096 _DOWNLOAD_CHUNK_SIZE = 8192 @@ -375,19 +377,20 @@ def __exit__( @staticmethod def _read_error_body(response: requests.Response) -> bytes: - """Read a bounded snippet of the response body for error messages. + """Read a bounded prefix of the response body for error messages and TEA error parsing. - Returns up to ``_ERROR_BODY_SNIPPET_BYTES + 1`` bytes so callers can - detect truncation. Falls back to ``response.content`` when the raw - stream is empty (e.g. already consumed by the transport layer). + Returns up to ``_ERROR_BODY_PARSE_BYTES`` bytes; callers compare the + length with ``_ERROR_BODY_SNIPPET_BYTES`` to detect a truncated snippet. + Falls back to ``response.content`` when the raw stream is empty + (e.g. already consumed by the transport layer). """ - limit = _ERROR_BODY_SNIPPET_BYTES + limit = _ERROR_BODY_PARSE_BYTES try: if response.raw: response.raw.decode_content = True - raw_bytes = response.raw.read(limit + 1) if response.raw else b"" + raw_bytes = response.raw.read(limit) if response.raw else b"" if not raw_bytes: - raw_bytes = response.content[: limit + 1] + raw_bytes = response.content[:limit] except Exception: raw_bytes = b"" return raw_bytes diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 2b5cf52..e373ac2 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -354,6 +354,28 @@ def test_400_non_json_body_has_no_error_type(self, http_client): http_client.get_json("/products") assert exc.value.error_type is None + @responses.activate + @pytest.mark.parametrize("status, exc_type", [(400, TeaRequestError), (404, TeaNotFoundError)]) + def test_long_message_keeps_error_type(self, http_client, status, exc_type): + """A body past the 200-char snippet limit still yields its error type; the message stays truncated.""" + body = {"error": "INVALID_PAGE_TOKEN", "message": "m" * 500} + responses.get(f"{LEGACY_BASE_URL}/products", status=status, json=body) + with pytest.raises(exc_type) as exc: + http_client.get_json("/products") + assert exc.value.error_type == "INVALID_PAGE_TOKEN" + if exc_type is TeaRequestError: + assert "(truncated)" in str(exc.value) + assert "m" * 300 not in str(exc.value) + + @responses.activate + def test_error_key_past_read_bound_not_found(self, http_client): + """Only the first 4 KiB of a 4xx body is read, so an ``error`` key after it is not seen.""" + body = '{"padding": "' + "x" * 5000 + '", "error": "INVALID_REQUEST"}' + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body=body) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + @responses.activate def test_3xx_has_no_error_type(self, http_client): responses.get(f"{LEGACY_BASE_URL}/products", status=301) From 72be6e90035d1f7474bb5b92a6081b61c6821afc Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:10:16 +0500 Subject: [PATCH 16/29] fix: TEA error parsing tolerates deeply nested bodies --- src/libtea/_http.py | 2 +- tests/client/test_http.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index b9169ba..983e601 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -49,7 +49,7 @@ def _tea_error_type(raw: bytes) -> str | None: """ try: body = json.loads(raw) - except ValueError: # includes JSONDecodeError and UnicodeDecodeError; empty input + except (ValueError, RecursionError): # ValueError: bad JSON/UTF-8, empty; RecursionError: deep nesting on 3.11 return None error = body.get("error") if isinstance(body, dict) else None return error if isinstance(error, str) else None diff --git a/tests/client/test_http.py b/tests/client/test_http.py index e373ac2..b41cc42 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -319,12 +319,21 @@ class TestErrorTypeFromBody: """Every 4xx surfaces the TEA ``error`` value when the body is a JSON object with a string ``error``.""" @responses.activate - def test_400_carries_error_type(self, http_client): - responses.get(f"{LEGACY_BASE_URL}/products", status=400, json={"error": "INVALID_PAGE_TOKEN"}) + @pytest.mark.parametrize("status, error", [(400, "INVALID_PAGE_TOKEN"), (406, "NO_ACCEPTABLE_FORMAT")]) + def test_4xx_carries_error_type(self, http_client, status, error): + responses.get(f"{LEGACY_BASE_URL}/products", status=status, json={"error": error}) with pytest.raises(TeaRequestError) as exc: http_client.get_json("/products") - assert exc.value.error_type == "INVALID_PAGE_TOKEN" - assert "INVALID_PAGE_TOKEN" in str(exc.value) + assert exc.value.error_type == error + assert error in str(exc.value) + + @responses.activate + def test_400_deeply_nested_body_has_no_error_type(self, http_client): + """json.loads raises RecursionError (not ValueError) on deep nesting under Python 3.11.""" + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body=b"[" * 4000) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None @responses.activate def test_unknown_error_value_passes_through(self, http_client): From 82e6c15a130c7b25c1ec94161ed93e1e3c2afeef Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:15:39 +0500 Subject: [PATCH 17/29] feat: conformance checks for TEA 1.0.0 pagination tokens and TEI URIs --- README.md | 2 +- docs/cli.md | 2 +- docs/conformance.md | 9 ++- src/libtea/conformance/__init__.py | 11 ++- src/libtea/conformance/_checks.py | 53 +++++++++++-- src/libtea/conformance/plugin.py | 3 +- tests/unit/test_conformance_checks.py | 108 ++++++++++++++++++++++++-- 7 files changed, 166 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c115670..6d1838a 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,7 @@ _TEA_CLI_COMPLETE=fish_source tea-cli | source ## Conformance testing -The conformance suite validates that a TEA server correctly implements the specification. It runs 26 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns (UUID format, pagination, camelCase). +The conformance suite validates that a TEA server correctly implements the specification. It runs 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns (UUID format, pagination, camelCase). ### Programmatic diff --git a/docs/cli.md b/docs/cli.md index d8beec5..75977d2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -308,7 +308,7 @@ tea-cli get-cle [--entity TYPE] UUID ### conformance -Run conformance checks against a TEA server. Validates that the server correctly implements the TEA specification by running 27 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. +Run conformance checks against a TEA server. Validates that the server correctly implements the TEA specification by running 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. ``` tea-cli conformance --base-url URL [OPTIONS] diff --git a/docs/conformance.md b/docs/conformance.md index 3151945..072882d 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -1,6 +1,6 @@ # Conformance Test Suite -The `libtea.conformance` module validates that a TEA server correctly implements the [Transparency Exchange API specification](https://github.com/CycloneDX/transparency-exchange-api). It runs 27 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. +The `libtea.conformance` module validates that a TEA server correctly implements the [Transparency Exchange API specification](https://github.com/CycloneDX/transparency-exchange-api). It runs 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. ## Installation @@ -139,7 +139,7 @@ Each check returns one of four statuses: | Check | Description | |-------|-------------| | `discovery` | Discover a TEI and verify the server returns results | -| `discovery_404` | Verify unknown TEI returns 404 or empty list | +| `discovery_404` | Verify unknown TEI returns 404 (TEA 0.x also accepts an empty list; on TEA 1.x a successful discovery response must be a non-empty array) | ### Products (5 checks) @@ -188,12 +188,13 @@ Each check returns one of four statuses: | `component_release_cle` | Get CLE data for a component release | | `cle_event_ordering` | Verify CLE events are ordered by id descending | -### Cross-cutting (3 checks) +### Cross-cutting (4 checks) | Check | Description | |-------|-------------| | `uuid_format` | Validate all collected UUIDs match canonical lowercase format | -| `pagination_fields` | Verify paginated listing exposes valid pagination metadata fields | +| `pagination_fields` | TEA 0.x: verify the `/products` listing exposes valid offset metadata (`pageSize`, `pageStartIndex`, `totalResults`, `timestamp`). TEA 1.x: verify the listing honours `pageSize` (no more than 10 results for `pageSize=10`) | +| `pagination_token` | TEA 1.x: request `/products` with `pageSize=1`, follow `nextPageToken`, and verify the next page returns different products. Skipped on TEA 0.x or when the server has fewer than two products | | `camel_case_fields` | Confirm server uses camelCase field names | ## CI integration diff --git a/src/libtea/conformance/__init__.py b/src/libtea/conformance/__init__.py index 482cc81..b210e22 100644 --- a/src/libtea/conformance/__init__.py +++ b/src/libtea/conformance/__init__.py @@ -3,16 +3,21 @@ Usage (programmatic):: from libtea.conformance import run_conformance - result = run_conformance(base_url="https://tea.example.com/v1", tei="urn:tei:...") + result = run_conformance( + base_url="https://tea.example.com/v1", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + ) print(f"{result.passed} passed, {result.failed} failed, {result.warned} warned, {result.skipped} skipped") Usage (CLI):: - tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." + tea-cli conformance --base-url https://tea.example.com/v1 \\ + --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" Usage (pytest plugin):: - pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." + pytest --tea-base-url https://tea.example.com/v1 \\ + --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" """ from __future__ import annotations diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index 7e96ae5..ffb7e47 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -13,13 +13,15 @@ from libtea.client import TeaClient from libtea.conformance._types import CheckResult, CheckStatus -from libtea.exceptions import TeaError, TeaNotFoundError +from libtea.exceptions import TeaError, TeaNotFoundError, TeaValidationError # Canonical zero UUID used for 404 tests. _ZERO_UUID = "00000000-0000-0000-0000-000000000000" _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_EMPTY_DISCOVERY = "a successful discovery response must be a non-empty array (TEA 1.0.0)" + # --------------------------------------------------------------------------- # Context @@ -64,6 +66,11 @@ def _warn(name: str, msg: str) -> CheckResult: return CheckResult(name=name, status=CheckStatus.WARN, message=msg) +def _is_empty_discovery(exc: TeaError) -> bool: + """True when ``discover()`` rejected an empty array, which TEA 1.x forbids in a successful response.""" + return isinstance(exc, TeaValidationError) and "non-empty array" in str(exc) + + def _collect(ctx: CheckContext, uuid: str | None) -> None: """Append a UUID to the collected list if it looks non-empty.""" if uuid: @@ -83,6 +90,8 @@ def check_discovery(client: TeaClient, ctx: CheckContext) -> CheckResult: try: results = client.discover(ctx.tei) except TeaError as exc: + if _is_empty_discovery(exc): + return _fail(name, f"discover() returned an empty array: {_EMPTY_DISCOVERY}") return _fail(name, f"discover() failed: {exc}", details=str(exc)) if not results: return _fail(name, "discover() returned empty list") @@ -93,18 +102,24 @@ def check_discovery(client: TeaClient, ctx: CheckContext) -> CheckResult: def check_discovery_404(client: TeaClient, ctx: CheckContext) -> CheckResult: - """Discover a fake TEI and expect TeaNotFoundError or empty list.""" + """Discover a fake TEI and expect TeaNotFoundError (or, on TEA 0.x, an empty list).""" name = "discovery_404" - fake_tei = "urn:tei:uuid:nonexistent.example.com:00000000-0000-0000-0000-000000000000" + if client.is_legacy: + fake_tei = f"urn:tei:uuid:nonexistent.example.com:{_ZERO_UUID}" + else: + fake_tei = f"tei://nonexistent.example.com/uuid/{_ZERO_UUID}" try: results = client.discover(fake_tei) except TeaNotFoundError: return _pass(name, "Server returned 404 for unknown TEI") except TeaError as exc: + if _is_empty_discovery(exc): + return _fail(name, f"Expected 404 for unknown TEI, got an empty array: {_EMPTY_DISCOVERY}") return _fail(name, f"Unexpected error: {exc}", details=str(exc)) if not results: return _pass(name, "Server returned empty list for unknown TEI") - return _fail(name, f"Expected 404 or empty list, got {len(results)} result(s)") + expected = "404 or empty list" if client.is_legacy else "404" + return _fail(name, f"Expected {expected}, got {len(results)} result(s)") # --------------------------------------------------------------------------- @@ -494,6 +509,7 @@ def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult issues: list[str] = [] if client.is_legacy: + passed = "Pagination metadata fields present and valid" if result.page_size != 10: issues.append(f"page_size: requested 10, got {result.page_size!r}") if result.page_start_index is None or result.page_start_index < 0: @@ -502,12 +518,34 @@ def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult issues.append(f"total_results is missing or negative: {result.total_results!r}") if not result.timestamp: issues.append("timestamp is missing") - elif len(result.results) > 10: + else: # TEA 1.x pages carry no totals; hasNext/nextPageToken consistency is enforced by the model. - issues.append(f"page_size: requested 10, got {len(result.results)} results") + passed = f"Page size respected ({len(result.results)} result(s) for pageSize=10)" + if len(result.results) > 10: + issues.append(f"page_size: requested 10, got {len(result.results)} results") if issues: return _fail(name, "Pagination metadata issues", details="; ".join(issues)) - return _pass(name, "Pagination metadata fields present and valid") + return _pass(name, passed) + + +def check_pagination_token(client: TeaClient, ctx: CheckContext) -> CheckResult: + """Follow ``nextPageToken`` from a one-item page and expect a different page back (TEA 1.x only).""" + name = "pagination_token" + if client.is_legacy: + return _skip(name, "pageToken pagination applies to TEA 1.x only") + try: + first = client.list_products(page_size=1) + if not first.next_page_token: + return _skip(name, "Fewer than two products on server") + second = client.list_products(page_token=first.next_page_token, page_size=1) + except TeaError as exc: + return _fail(name, f"list_products() failed: {exc}", details=str(exc)) + repeated = {p.uuid for p in first.results} & {p.uuid for p in second.results} + if repeated: + return _fail( + name, "pageToken did not advance: page 2 repeated results from page 1", details=", ".join(repeated) + ) + return _pass(name, "pageToken advanced to a different page") def check_camel_case_fields(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -558,5 +596,6 @@ def check_camel_case_fields(client: TeaClient, ctx: CheckContext) -> CheckResult # Cross-cutting check_uuid_format, check_pagination_fields, + check_pagination_token, check_camel_case_fields, ] diff --git a/src/libtea/conformance/plugin.py b/src/libtea/conformance/plugin.py index dcf4486..89ae53a 100644 --- a/src/libtea/conformance/plugin.py +++ b/src/libtea/conformance/plugin.py @@ -5,7 +5,8 @@ Usage:: - pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." + pytest --tea-base-url https://tea.example.com/v1 \\ + --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" All conformance module imports are deferred to function bodies so that ``pytest-cov`` can track them. The ``pytest11`` entry point causes this diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 9fc7b46..63e07bd 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -1,4 +1,5 @@ import responses +from responses import matchers from libtea.client import TeaClient from libtea.conformance._checks import ( @@ -23,6 +24,7 @@ check_list_product_releases, check_list_products, check_pagination_fields, + check_pagination_token, check_product_cle, check_product_release_cle, check_product_release_collection_latest, @@ -34,7 +36,7 @@ check_uuid_format, ) from libtea.conformance._types import CheckStatus -from tests.spec_data import V1_BASE_URL +from tests.spec_data import COMPONENT, PRODUCT, V1_BASE_URL, product BASE_URL = "https://api.example.com/tea/v0.4.0" @@ -257,6 +259,14 @@ def test_fail_empty_list(self): result = check_discovery(client, ctx) assert result.status == CheckStatus.FAIL + @responses.activate + def test_v1_fail_empty_array(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + result = check_discovery(v1, CheckContext(tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1")) + assert result.status == CheckStatus.FAIL + assert "must be a non-empty array" in result.message + assert "Unexpected error" not in result.message + @responses.activate def test_does_not_overwrite_existing_uuid(self): responses.get(f"{BASE_URL}/discovery", json=_DISCOVERY_RESULT) @@ -276,6 +286,37 @@ def test_fail_on_server_error(self): class TestCheckDiscovery404: + @responses.activate + def test_v1_uses_tei_uri(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 1 + assert "tei%3A%2F%2Fnonexistent.example.com%2Fuuid%2F" + _ZERO_UUID in responses.calls[0].request.url + + @responses.activate + def test_legacy_keeps_urn(self, legacy): + responses.get(f"{BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}) + result = check_discovery_404(legacy, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 1 + assert "urn%3Atei%3Auuid%3Anonexistent.example.com" in responses.calls[0].request.url + + @responses.activate + def test_v1_fail_on_empty_array(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "must be a non-empty array" in result.message + assert "Unexpected error" not in result.message + + @responses.activate + def test_v1_fail_when_results_returned(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=_DISCOVERY_RESULT) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert result.message == "Expected 404, got 1 result(s)" + @responses.activate def test_pass_on_404(self): responses.get(f"{BASE_URL}/discovery", json={"error": "OBJECT_UNKNOWN"}, status=404) @@ -1268,6 +1309,7 @@ def test_v1_pass_within_page_size(self, v1): ) result = check_pagination_fields(v1, CheckContext()) assert result.status == CheckStatus.PASS + assert result.message == "Page size respected (1 result(s) for pageSize=10)" @responses.activate def test_v1_fail_when_page_overflows(self, v1): @@ -1277,6 +1319,60 @@ def test_v1_fail_when_page_overflows(self, v1): assert "got 11 results" in result.details +class TestCheckPaginationToken: + @responses.activate + def test_pass(self, v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1"})], + json={"hasNext": True, "nextPageToken": "t", "results": [product(PRODUCT, "A")]}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1", "pageToken": "t"})], + json={"hasNext": False, "results": [product(COMPONENT, "B")]}, + ) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 2 + + @responses.activate + def test_same_page_fails(self, v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1"})], + json={"hasNext": True, "nextPageToken": "t", "results": [product(PRODUCT, "A")]}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1", "pageToken": "t"})], + json={"hasNext": False, "results": [product(PRODUCT, "A")]}, + ) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert len(responses.calls) == 2 + + @responses.activate + def test_single_page_skips(self, v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": False, "results": []}) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.SKIP + assert len(responses.calls) == 1 + + @responses.activate + def test_skips_legacy(self, legacy): + result = check_pagination_token(legacy, CheckContext()) + assert result.status == CheckStatus.SKIP + assert len(responses.calls) == 0 + + @responses.activate + def test_fail_on_server_error(self, v1): + responses.get(f"{V1_BASE_URL}/products", status=500) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "list_products() failed" in result.message + + class TestCheckCamelCaseFields: def test_pass_with_collected_uuids(self): client = _make_client() @@ -1369,7 +1465,9 @@ def test_discovery_checks_come_first(self): assert ALL_CHECKS[1].__name__ == "check_discovery_404" def test_cross_cutting_checks_come_last(self): - last_three = [c.__name__ for c in ALL_CHECKS[-3:]] - assert "check_uuid_format" in last_three - assert "check_pagination_fields" in last_three - assert "check_camel_case_fields" in last_three + assert [c.__name__ for c in ALL_CHECKS[-4:]] == [ + "check_uuid_format", + "check_pagination_fields", + "check_pagination_token", + "check_camel_case_fields", + ] From d53e76d64c92254bd08f642a4acf945c6459e790 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:19:31 +0500 Subject: [PATCH 18/29] feat: discovery by PURL (TEA 1.0.0) --- src/libtea/cli.py | 17 ++++++++++++---- src/libtea/client.py | 17 ++++++++++++---- src/libtea/protocols.py | 2 +- tests/cli/test_cli.py | 36 +++++++++++++++++++++++++++++++++- tests/client/test_client_v1.py | 31 +++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 33f1dbe..6b23e13 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -430,11 +430,15 @@ def app( @app.command() -@click.argument("tei") +@click.argument("tei", required=False) +@click.option( + "--purl", default=None, help="Resolve a PURL on the server given by --base-url (TEA 1.x), instead of a TEI" +) @click.option("--quiet", "-q", is_flag=True, help="Output only UUIDs, one per line") @shared_options def discover( - tei: str, + tei: str | None, + purl: str | None, quiet: bool, base_url: str | None, token: str | None, @@ -445,17 +449,22 @@ def discover( port: int | None, allow_private_ips: bool, ) -> None: - """Resolve a TEI to product release UUID(s). + """Resolve a TEI, or a PURL (--purl, TEA 1.x), to product release UUID(s). \b Examples: tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --domain example.com tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --quiet + tea-cli discover --purl 'pkg:pypi/requests@2.31.0' --base-url https://tea.example.com/tea/v1.0.0 """ + if (tei is None) == (purl is None): + raise click.UsageError("Provide exactly one of TEI or --purl") + if purl is not None and not base_url: + _error("--purl requires --base-url") with _client_session( base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips ) as client: - result = client.discover(tei) + result = client.discover(tei, purl=purl) if quiet: for d in result: print(d.product_release_uuid) diff --git a/src/libtea/client.py b/src/libtea/client.py index 1924041..e0a760c 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -250,22 +250,31 @@ def from_well_known( # --- Discovery --- - def discover(self, tei: str) -> list[DiscoveryInfo]: - """Resolve a TEI to product release UUID(s) via the discovery endpoint. + def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[DiscoveryInfo]: + """Resolve a TEI, or a PURL within this server's inventory, to product release UUID(s). + + Exactly one of ``tei`` and ``purl`` must be given. Discovery by PURL needs an API base the + client already knows and is only available on TEA 1.x servers. Args: tei: TEI string, sent verbatim (e.g. ``tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1`` or ``urn:tei:purl:example.com:pkg:pypi/lib@1.0``). + purl: Package URL, sent verbatim (e.g. ``pkg:maven/org.apache.logging.log4j/log4j-core@2.24.3``). Returns: List of discovery results, each with a product release UUID and servers. Raises: - TeaValidationError: If the response is malformed, or empty on a TEA 1.x server. + TeaValidationError: If not exactly one of ``tei`` and ``purl`` is given, if ``purl`` is used on a + TEA 0.x server, or if the response is malformed, or empty on a TEA 1.x server. TeaConnectionError: On network failure. """ + if (tei is None) == (purl is None): + raise TeaValidationError("Provide exactly one of tei or purl") + if purl is not None and self.is_legacy: + raise TeaValidationError("Discovery by PURL requires a TEA 1.x server") # requests auto-encodes query params — do NOT pre-encode with quote() - data = self._http.get_json("/discovery", params={"tei": tei}) + data = self._http.get_json("/discovery", params={"tei": tei} if tei is not None else {"purl": purl}) results = _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) if not results and not self.is_legacy: raise TeaValidationError("Invalid discovery response: expected a non-empty array") diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index 81e5240..3855845 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -48,7 +48,7 @@ class TeaClientProtocol(Protocol): # --- Discovery --- - def discover(self, tei: str) -> list[DiscoveryInfo]: ... + def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[DiscoveryInfo]: ... # --- Products --- diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 5e458c8..8c79b4a 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -13,7 +13,7 @@ from click.testing import CliRunner # noqa: E402 from libtea.cli import app # noqa: E402 -from tests.spec_data import LEGACY # noqa: E402 +from tests.spec_data import LEGACY, PRODUCT_RELEASE, V1_BASE_URL # noqa: E402 runner = CliRunner() @@ -461,6 +461,40 @@ def test_discover_not_found(self): assert result.exit_code == 1 +class TestCLIDiscoverPurl: + @responses.activate + def test_discover_purl(self): + purl = "pkg:x/y" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"purl": purl})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + result = runner.invoke(app, ["--json", "discover", "--purl", purl, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0 + assert json.loads(result.output)[0]["productReleaseUuid"] == PRODUCT_RELEASE + + @responses.activate + def test_purl_requires_base_url(self): + result = runner.invoke(app, ["discover", "--purl", "pkg:x/y", "--domain", "example.com"]) + assert result.exit_code == 1 + assert "--purl requires --base-url" in _all_output(result) + assert len(responses.calls) == 0 + + @responses.activate + @pytest.mark.parametrize("args", [[], ["tei://example.com/uuid/x", "--purl", "pkg:x/y"]]) + def test_exactly_one_of_tei_or_purl(self, args): + result = runner.invoke(app, ["discover", *args, "--base-url", V1_BASE_URL]) + assert result.exit_code == 2 + assert "exactly one of TEI or --purl" in _all_output(result) + assert len(responses.calls) == 0 + + class TestCLIDiscoveryPath: """P2-3: Tests for --domain discovery path.""" diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 509cc58..f8a33c8 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -92,6 +92,37 @@ def test_discover_passes_tei_verbatim(v1): assert v1.discover(tei)[0].product_release_uuid == PRODUCT_RELEASE +@responses.activate +def test_discover_by_purl(v1): + purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.24.3" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"purl": purl})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + assert v1.discover(purl=purl)[0].product_release_uuid == PRODUCT_RELEASE + + +@responses.activate +@pytest.mark.parametrize("kwargs", [{}, {"tei": "tei://example.com/uuid/x", "purl": "pkg:x/y"}]) +def test_discover_needs_exactly_one(v1, kwargs): + with pytest.raises(TeaValidationError, match="exactly one"): + v1.discover(**kwargs) + assert len(responses.calls) == 0 + + +@responses.activate +def test_purl_rejected_on_legacy(legacy): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + legacy.discover(purl="pkg:x/y") + assert len(responses.calls) == 0 + + @responses.activate def test_discover_empty_array_rejected_on_v1(v1): responses.get(f"{V1_BASE_URL}/discovery", json=[]) From 68356f5cba277ef139753cf90194721946822cdd Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:27:04 +0500 Subject: [PATCH 19/29] feat: download server-hosted artifacts via TEA 1.0.0 download endpoint --- src/libtea/_http.py | 24 +++++++-- src/libtea/cli.py | 26 +++++++--- src/libtea/client.py | 61 ++++++++++++++++++++++- src/libtea/protocols.py | 11 ++++ tests/cli/test_cli.py | 35 ++++++++++++- tests/client/test_client_v1.py | 91 +++++++++++++++++++++++++++++++++- tests/client/test_download.py | 17 +++++++ 7 files changed, 250 insertions(+), 15 deletions(-) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index 983e601..38cab9e 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -269,6 +269,8 @@ def download_with_hashes( *, max_download_bytes: int | None = None, allow_private_ips: bool = False, + params: dict[str, str] | None = None, + authenticated: bool = False, ) -> dict[str, str]: """Download a file and compute checksums on-the-fly. @@ -276,21 +278,33 @@ def download_with_hashes( is not leaked to third-party artifact hosts (CDNs, Maven Central, etc.). Redirects are followed manually with SSRF validation at each hop. + With ``authenticated=True`` the first request goes through the API session + (credentials, retries) and, like :meth:`get_json`, trusts the configured API + base without an SSRF check. Every redirect hop still uses the unauthenticated + session, so credentials never leave the API base, not even on a same-host redirect. + Args: - url: Direct download URL. + url: Direct download URL. Must be under the API base when ``authenticated``. dest: Local file path to write to. Parent directories are created. algorithms: Optional list of checksum algorithm names to compute. max_download_bytes: Optional maximum download size in bytes. + params: Optional query parameters for the first request. + authenticated: Send the first request with the API credentials. Returns: Dict mapping algorithm name to hex digest string. Raises: + ValueError: If ``authenticated`` and ``url`` is not under the API base. TeaConnectionError: On network failure. Partial files are deleted. TeaChecksumError: If an unsupported algorithm is requested. TeaValidationError: If download exceeds max_download_bytes or fails SSRF check. """ - _validate_download_url(url, allow_private_ips=allow_private_ips) + if authenticated: + if not url.startswith(f"{self._base_url}/"): + raise ValueError(f"Authenticated download URL must be under the API base {self._base_url}") + else: + _validate_download_url(url, allow_private_ips=allow_private_ips) logger.debug("DOWNLOAD %s -> %s", url, dest) hashers = _build_hashers(algorithms) if algorithms else {} @@ -301,12 +315,13 @@ def download_with_hashes( # Follow redirects manually with SSRF validation at each hop current_url = url + session = self._session if authenticated else download_session response = None try: redirects = 0 while True: - response = download_session.get( - current_url, stream=True, timeout=self._timeout, allow_redirects=False + response = session.get( + current_url, params=params, stream=True, timeout=self._timeout, allow_redirects=False ) if 300 <= response.status_code < 400: redirects += 1 @@ -317,6 +332,7 @@ def download_with_hashes( raise TeaRequestError(f"Redirect without Location header: HTTP {response.status_code}") current_url = urljoin(current_url, location) _validate_download_url(current_url, allow_private_ips=allow_private_ips) + session, params = download_session, None response.close() response = None continue diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 6b23e13..b1c7dc6 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -17,7 +17,7 @@ from contextlib import contextmanager from inspect import signature as _signature from pathlib import Path -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from urllib.parse import urlparse import click @@ -1063,7 +1063,7 @@ def _download_from_tei( collection = client.get_product_release_collection_latest(disc.product_release_uuid) for art_idx, artifact in enumerate(collection.artifacts): for fmt in artifact.formats: - if not fmt.url: + if not fmt.url and client.is_legacy: continue filename = _artifact_filename(fmt, artifact, art_idx) filename = _deduplicate_filename(filename, seen_filenames) @@ -1076,12 +1076,22 @@ def _download_from_tei( downloaded += 1 continue try: - client.download_artifact( - fmt.url, - dest_path, - verify_checksums=checksums or None, - max_download_bytes=max_download_bytes, - ) + if fmt.url: + client.download_artifact( + fmt.url, + dest_path, + verify_checksums=checksums or None, + max_download_bytes=max_download_bytes, + ) + else: # TEA 1.x: the server hosts formats without a url + client.download_artifact_content( + artifact.uuid, + dest_path, + media_type=cast(str, fmt.media_type), # required on TEA 1.x + version=artifact.version, + verify_checksums=checksums or None, + max_download_bytes=max_download_bytes, + ) if not quiet: checksum_note = " (checksum OK)" if checksums else "" print(f"Downloaded {filename}{checksum_note}", file=sys.stderr) diff --git a/src/libtea/client.py b/src/libtea/client.py index e0a760c..5eef3f3 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -730,13 +730,70 @@ def download_artifact( TeaConnectionError: On network failure. TeaValidationError: If download exceeds max_download_bytes. """ + return self._download(url, dest, verify_checksums, max_download_bytes) + + def download_artifact_content( + self, + uuid: str, + dest: Path, + *, + media_type: str, + version: int | None = None, + verify_checksums: list[Checksum] | None = None, + max_download_bytes: int | None = None, + ) -> Path: + """Download artifact content hosted by the TEA server itself (a format without ``url``). + + Fetches ``/artifact/{uuid}/{version}/download?mediaType=...`` (``latest`` when + ``version`` is omitted) with the client's credentials. A redirect is followed + without credentials, so the token is only ever sent to the API base. + + Args: + uuid: Artifact UUID. + dest: Local file path to write to. + media_type: Media type of the format to download. + version: Artifact version number (starts at 1), or ``None`` for the latest. + verify_checksums: Optional list of checksums to verify after download. + On mismatch the downloaded file is deleted. + max_download_bytes: Optional maximum download size in bytes. + + Returns: + The destination path. + + Raises: + TeaValidationError: On a TEA 0.x server, or if download exceeds max_download_bytes. + TeaRequestError: If the server has no format for ``media_type`` (``NO_ACCEPTABLE_FORMAT``). + TeaChecksumError: If checksum verification fails. + TeaConnectionError: On network failure. + """ + if self.is_legacy: + raise TeaValidationError("Server-hosted artifact download requires a TEA 1.x server") + if version is not None: + _validate_artifact_version(version) + revision = "latest" if version is None else version + url = f"{self._http._base_url}/artifact/{_validate_path_segment(uuid)}/{revision}/download" + return self._download( + url, dest, verify_checksums, max_download_bytes, params={"mediaType": media_type}, authenticated=True + ) + + def _download( + self, + url: str, + dest: Path, + verify_checksums: list[Checksum] | None, + max_download_bytes: int | None, + *, + params: dict[str, str] | None = None, + authenticated: bool = False, + ) -> Path: + """Download with on-the-fly hashing, warning on weak algorithms and verifying checksums.""" algorithms = [cs.algorithm_type.value for cs in verify_checksums] if verify_checksums else None if algorithms: weak = set(algorithms) & WEAK_HASH_ALGORITHMS if weak: warnings.warn( f"Verifying with weak hash algorithm(s): {', '.join(sorted(weak))}. Prefer SHA-256 or stronger.", - stacklevel=2, + stacklevel=3, ) computed = self._http.download_with_hashes( url, @@ -744,6 +801,8 @@ def download_artifact( algorithms=algorithms, max_download_bytes=max_download_bytes, allow_private_ips=self._allow_private_ips, + params=params, + authenticated=authenticated, ) if verify_checksums: diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index 3855845..05be037 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -137,6 +137,17 @@ def download_artifact( max_download_bytes: int | None = None, ) -> Path: ... + def download_artifact_content( + self, + uuid: str, + dest: Path, + *, + media_type: str, + version: int | None = None, + verify_checksums: list[Checksum] | None = None, + max_download_bytes: int | None = None, + ) -> Path: ... + # --- Pagination iterators --- def iter_products(self, id_type: str, id_value: str, *, page_size: int = 100) -> Iterator[Product]: ... diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 8c79b4a..ad33a19 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -13,7 +13,14 @@ from click.testing import CliRunner # noqa: E402 from libtea.cli import app # noqa: E402 -from tests.spec_data import LEGACY, PRODUCT_RELEASE, V1_BASE_URL # noqa: E402 +from tests.spec_data import ( # noqa: E402 + ARTIFACT, + LEGACY, + PRODUCT_RELEASE, + V1_ARTIFACT, + V1_BASE_URL, + V1_COLLECTION, +) runner = CliRunner() @@ -1869,6 +1876,32 @@ def test_tei_mode_skips_formats_without_url(self, tmp_path): assert result.exit_code == 0 assert (dest / "sbom.json").exists() + @responses.activate + def test_tei_mode_v1_downloads_server_hosted_format(self, tmp_path): + """On TEA 1.x a format without url is fetched from the artifact download endpoint.""" + tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" + media = "application/vnd.cyclonedx+json" + empty_json_sha256 = "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + fmt = {"mediaType": media, "checksums": [{"algType": "SHA-256", "algValue": empty_json_sha256}]} + collection = {**V1_COLLECTION, "artifacts": [{**V1_ARTIFACT, "name": "SBOM", "formats": [fmt]}]} + responses.get( + f"{V1_BASE_URL}/discovery", + json=[ + {"productReleaseUuid": PRODUCT_RELEASE, "servers": [{"rootUrl": V1_BASE_URL, "versions": ["1.0.0"]}]} + ], + ) + responses.get(f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}/collection/latest", json=collection) + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + match=[matchers.query_param_matcher({"mediaType": media})], + body=b"{}", + ) + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + assert (dest / "SBOM.cdx.json").read_bytes() == b"{}" + assert "Downloaded SBOM.cdx.json (checksum OK)" in _all_output(result) + def test_tei_mode_rejects_checksum_flag(self, tmp_path): """--checksum is rejected in TEI mode.""" tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index f8a33c8..9f4913b 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -6,7 +6,8 @@ from libtea._validation import _validate, _validate_list from libtea.client import TeaClient -from libtea.exceptions import TeaServerError, TeaValidationError +from libtea.exceptions import TeaChecksumError, TeaRequestError, TeaServerError, TeaValidationError +from libtea.models import Checksum from tests.spec_data import ( ARTIFACT, COMPONENT, @@ -359,3 +360,91 @@ def test_get_component_release_legacy_nested(legacy): json={"release": V1_RELEASE, "latestCollection": V1_COLLECTION}, ) assert legacy.get_component_release(COMPONENT_RELEASE).release.uuid == COMPONENT_RELEASE + + +# --- Server-hosted artifact download (TEA 1.0.0) --- + +MEDIA = "application/vnd.cyclonedx+json" +EMPTY_JSON_SHA256 = "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" # sha256(b"{}") + + +@responses.activate +def test_token_not_forwarded_on_redirect(tmp_path): # Review Focus 4 + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + match=[matchers.query_param_matcher({"mediaType": MEDIA})], + status=302, + headers={"Location": "https://cdn.example.com/blob/abc"}, + ) + responses.get("https://cdn.example.com/blob/abc", body=b"{}") + c.download_artifact_content( + ARTIFACT, + tmp_path / "a.json", + media_type=MEDIA, + version=2, + verify_checksums=[Checksum(algorithm_type="SHA-256", algorithm_value=EMPTY_JSON_SHA256)], + ) + assert responses.calls[0].request.headers["Authorization"] == "Bearer secret" + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_token_not_forwarded_on_redirect_to_api_host(tmp_path): + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "/blobs/abc"}, + ) + responses.get("https://api.example.com/blobs/abc", body=b"{}") + c.download_artifact_content(ARTIFACT, tmp_path / "a.json", media_type=MEDIA, version=2) + assert responses.calls[1].request.url == "https://api.example.com/blobs/abc" + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_latest_when_version_omitted(v1, tmp_path): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", body=b"{}") + assert v1.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA).read_bytes() == b"{}" + + +@responses.activate +def test_server_download_rejected_on_legacy(legacy, tmp_path): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + legacy.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA) + assert len(responses.calls) == 0 + + +@responses.activate +def test_server_download_no_acceptable_format(v1, tmp_path): + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", status=406, json={"error": "NO_ACCEPTABLE_FORMAT"} + ) + dest = tmp_path / "a" + with pytest.raises(TeaRequestError) as exc_info: + v1.download_artifact_content(ARTIFACT, dest, media_type=MEDIA) + assert exc_info.value.error_type == "NO_ACCEPTABLE_FORMAT" + assert not dest.exists() + + +@responses.activate +def test_server_download_checksum_mismatch_deletes_file(v1, tmp_path): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", body=b"[]") + dest = tmp_path / "a" + with pytest.raises(TeaChecksumError): + v1.download_artifact_content( + ARTIFACT, + dest, + media_type=MEDIA, + verify_checksums=[Checksum(algorithm_type="SHA-256", algorithm_value=EMPTY_JSON_SHA256)], + ) + assert not dest.exists() + + +@responses.activate +def test_server_download_trusts_private_api_base(tmp_path): + """The first hop is the configured API base, exempt from SSRF checks like every other API call.""" + c = TeaClient("https://10.0.0.5/tea/v1.0.0") + responses.get(f"https://10.0.0.5/tea/v1.0.0/artifact/{ARTIFACT}/latest/download", body=b"{}") + assert c.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA).read_bytes() == b"{}" diff --git a/tests/client/test_download.py b/tests/client/test_download.py index 9363419..cbe84bb 100644 --- a/tests/client/test_download.py +++ b/tests/client/test_download.py @@ -3,8 +3,10 @@ import pytest import responses +from libtea._http import TeaHttpClient from libtea.exceptions import TeaChecksumError from libtea.models import Checksum, ChecksumAlgorithm +from tests.spec_data import V1_BASE_URL ARTIFACT_URL = "https://artifacts.example.com/sbom.json" ARTIFACT_CONTENT = b'{"bomFormat": "CycloneDX", "specVersion": "1.5"}' @@ -118,3 +120,18 @@ def test_multi_checksum_partial_failure(self, client, tmp_path): with pytest.raises(TeaChecksumError, match="SHA-256"): client.download_artifact(ARTIFACT_URL, dest, verify_checksums=checksums) assert not dest.exists() + + +@responses.activate +def test_authenticated_off_base_rejected(tmp_path): + with TeaHttpClient(base_url=V1_BASE_URL, token="secret") as h, pytest.raises(ValueError, match="API base"): + h.download_with_hashes("https://evil.example.com/x", tmp_path / "x", authenticated=True) + assert len(responses.calls) == 0 + + +@responses.activate +@pytest.mark.parametrize("url", ["https://api.example.com.evil.com/x", "https://api.example.com@evil.com/x"]) +def test_authenticated_lookalike_host_rejected(tmp_path, url): + with TeaHttpClient(base_url="https://api.example.com", token="secret") as h, pytest.raises(ValueError): + h.download_with_hashes(url, tmp_path / "x", authenticated=True) + assert len(responses.calls) == 0 From 89ec29af0c0ef95b2bcb063fbafb45b6a2ab64a6 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:41:37 +0500 Subject: [PATCH 20/29] docs: TEA 1.0.0 alignment, upgrade notes, deferred items --- CLAUDE.md | 15 +-- README.md | 212 +++++++++++++++++++++++++++++++---------- docs/FUTURE.md | 28 ++++-- docs/cli.md | 59 ++++++++---- docs/conformance.md | 12 ++- src/libtea/__init__.py | 2 +- src/libtea/cli.py | 28 +++--- src/libtea/client.py | 8 +- src/libtea/models.py | 7 +- 9 files changed, 264 insertions(+), 107 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d06d220..0cc5dcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**py-libtea** is a hand-crafted Python client library for the Transparency Exchange API (TEA) v0.4.0. Consumer-focused (read-only); publisher API is not yet supported (blocked on TEA spec). Licensed Apache 2.0, maintained under sbomify. +**py-libtea** is a hand-crafted Python client library for the Transparency Exchange API (TEA) 1.0.0 with 0.4.0 server compatibility. Consumer-focused (read-only); publisher API is not yet supported (blocked on TEA spec). Licensed Apache 2.0, maintained under sbomify. ## Build & Dev Commands @@ -35,9 +35,9 @@ The library has a layered design with strict separation of concerns: ``` __init__.py Public API re-exports (all models, exceptions, client, discovery) -client.py TeaClient — high-level consumer API, checksum verification +client.py TeaClient — high-level consumer API, spec version resolution, checksum verification ↓ uses -_validation.py Input validation helpers (path segments, page size/offset, Pydantic wrappers) +_validation.py Input validation helpers (path segments, page size, Pydantic wrappers) _http.py TeaHttpClient — low-level requests wrapper, auth, streaming downloads Also: probe_endpoint() for endpoint failover _security.py SSRF protection (_validate_download_url, DNS rebinding checks, internal IP detection) @@ -52,7 +52,7 @@ _cli_entry.py Entry point wrapper that handles missing CLI extras gracefu conformance/ Conformance test suite (optional dependency: pytest) __init__.py Public API: run_conformance, CheckResult, CheckStatus, ConformanceResult - _checks.py 27 check functions organized by category, CheckContext shared state + _checks.py 28 check functions organized by category, CheckContext shared state _runner.py Orchestrates checks, collects results into ConformanceResult _types.py CheckStatus (StrEnum), CheckResult (frozen dataclass), ConformanceResult plugin.py pytest plugin — --tea-base-url options, parametrized test generation @@ -65,7 +65,8 @@ conformance/ Conformance test suite (optional dependency: pytest) - Downloads follow redirects manually with SSRF validation at each hop - Discovery redirects are validated against internal networks (SSRF protection via `_security._validate_download_url`) - `_validation._validate()` wraps Pydantic `ValidationError` into `TeaValidationError` so all client errors are `TeaError` subclasses -- Endpoint failover: `from_well_known()` probes candidates in priority order, skipping unreachable ones +- Endpoint failover: `from_well_known()` probes candidates in priority order, skipping unreachable ones. With `version=None` it negotiates 1.0.0 first, then 0.4.0, and the candidates span both versions +- `download_artifact_content()` (server-hosted artifact bytes) sends the token only on the first hop, to the API base; every redirect hop goes through the unauthenticated session with SSRF validation - `probe_endpoint()` lives in `_http.py` (not `client.py`) to maintain the HTTP layer boundary - `_raise_for_status()` reads 4xx bodies once, bounded to 4 KiB, to parse the TEA `error` value (`_tea_error_type`); the message snippet is cut to 200 characters. This avoids memory issues on streaming responses - CLI formatters in `_cli_fmt.py` escape all server-controlled strings with `rich.markup.escape()` to prevent Rich markup injection @@ -83,6 +84,8 @@ conformance/ Conformance test suite (optional dependency: pytest) - `ChecksumAlgorithm` values may arrive as `SHA_256` (underscore) or `SHA-256` (hyphen) from servers — the `@field_validator` in `Checksum` normalizes both - BLAKE3 is in the enum for spec completeness but NOT supported at runtime (not in stdlib `hashlib`) — raises `TeaChecksumError` - `Identifier.id_type` is typed as `str` (not `IdentifierType` enum) so unknown types from future spec versions pass through +- **Behaviour follows the negotiated version, never response-shape sniffing.** `TeaClient` resolves `spec_version` once (explicit argument, then the base URL's `/v` suffix, then `TEA_SPEC_VERSION` = 1.0.0) and exposes `is_legacy`. Pagination parameters, page-size limits, discovery by PURL and server-hosted downloads all branch on it. Never infer the version from what a response looks like +- **0.4.0 shims run only under the validation context `{"tea_legacy": True}`.** `_validation._validate(..., legacy=client.is_legacy)` supplies it; no context means 1.0.0 rules. Fields required by both versions are non-Optional model types; fields only 1.0.0 requires stay `X | None` and are rejected when missing outside the legacy context (`_v1_required` / `_v1_problems()` on `_TeaModel`). Building an MD5 `Checksum` by hand therefore needs `Checksum.model_validate({...}, context={"tea_legacy": True})` - CGNAT range (100.64.0.0/10, RFC 6598) is checked separately in SSRF protection because `ipaddress.is_private` misses it on Python 3.11+ ## TEA Spec Reference @@ -93,7 +96,7 @@ The TEA spec repo should be cloned to `/tmp/transparency-exchange-api/` for cros git clone https://github.com/CycloneDX/transparency-exchange-api /tmp/transparency-exchange-api ``` -Key spec files: `spec/openapi.yaml`, `discovery/readme.md`, `auth/readme.md` +Key spec files: `spec/openapi.yaml`, `discovery/readme.md`, `auth/readme.md`. The 0.4.0 baseline is `git -C /tmp/transparency-exchange-api show e0c986b:spec/openapi.yaml`. ## Design Docs diff --git a/README.md b/README.md index 6d1838a..63c43c5 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,28 @@ [![License](https://img.shields.io/github/license/sbomify/py-libtea.svg)](https://github.com/sbomify/py-libtea/blob/master/LICENSE) [![sbomified](https://sbomify.com/assets/images/logo/badge.svg)](https://trust.sbomify.com/component/py-libtea/) -Python client library for the [Transparency Exchange API (TEA)](https://transparency.exchange/) v0.4.0. +Python client library for the [Transparency Exchange API (TEA)](https://transparency.exchange/) 1.0.0, with compatibility for 0.4.0 servers. TEA is an open standard for discovering and retrieving software transparency artifacts (SBOMs, VEX, build metadata) for any software product or component. A [TEI identifier](https://github.com/CycloneDX/transparency-exchange-api/blob/main/discovery/readme.md) resolves via DNS to the right endpoint, similar to how email uses MX records — so consumers can fetch artifacts without knowing which server hosts them. **Specification:** [Ecma TC54-TG1](https://tc54.org/tea/) | [OpenAPI spec](https://github.com/CycloneDX/transparency-exchange-api) > **Status**: Alpha — API is subject to change. +> +> **Upgrading?** libtea now targets TEA 1.0.0. See [Upgrading](#upgrading) for the Python API changes. ### Features -- Auto-discovery via `.well-known/tea` and TEI URNs -- Flexible version negotiation — connects to older server versions within the same major series +- Auto-discovery via `.well-known/tea` and TEIs (`tei://` URIs; the legacy URN form for 0.4.0 servers) +- Discovery by PURL on TEA 1.x servers +- Spec version negotiation — prefers TEA 1.0.0, falls back to 0.4.0 servers, and fails over across both - Products, components, releases, and versioned collections - Search by PURL, CPE, or TEI identifier -- Pagination auto-iterators for search results +- Token-based pagination (`nextPageToken`) with auto-iterators for search results - Bulk parallel fetch for products, releases, and artifacts - Common Lifecycle Enumeration (CLE) — ECMA-428 lifecycle events -- Artifact download with on-the-fly checksum verification (MD5 through BLAKE2b) +- Artifact download with on-the-fly checksum verification (SHA-1 through BLAKE2b; MD5 on 0.4.0 servers only) +- Download of artifact content hosted by the TEA server itself (TEA 1.x) - Optional TTL response caching - Endpoint failover with SemVer-compatible version selection - SSRF protection — blocks private IPs, cloud metadata endpoints, DNS rebinding @@ -60,11 +64,11 @@ pip install libtea[conformance] ```python from libtea import TeaClient -# Auto-discover the sbomify TEA server from its .well-known/tea -with TeaClient.from_well_known("trust.sbomify.com") as client: +# Auto-discover the TEA server from the domain's .well-known/tea +with TeaClient.from_well_known("example.com") as client: # Discover a product by TEI results = client.discover( - "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ) for info in results: print(info.product_release_uuid, info.servers) @@ -78,54 +82,79 @@ Or connect directly to a known endpoint: ```python client = TeaClient( - base_url="https://trust.sbomify.com/tea/v0.4.0", + base_url="https://tea.example.com/tea/v1.0.0", # the /v suffix selects the spec version timeout=30.0, ) ``` -Using `from_well_known`, you can also override the spec version and timeout: +`from_well_known` negotiates the spec version for you (TEA 1.0.0 first, then 0.4.0). You can also pin a version and set the timeout: ```python client = TeaClient.from_well_known( - "trust.sbomify.com", + "example.com", timeout=15.0, - version="0.4.0", # default + version="0.4.0", # default: None, i.e. negotiate 1.0.0 first, then 0.4.0 ) ``` ## Usage +### Spec versions + +libtea speaks TEA 1.0.0 and 0.4.0. Each client knows which one it talks to, and its behaviour follows that version, never the shape of a response: + +```python +client = TeaClient("https://tea.example.com/tea/v0.4.0") +client.spec_version # "0.4.0" +client.is_legacy # True for 0.4.0, False for 1.x +``` + +The version comes from the `spec_version=` argument, else a `/v` suffix on `base_url`, else the default (`TEA_SPEC_VERSION`, `"1.0.0"`). A base URL without a version suffix, such as `https://tea.example.com/v1`, is treated as TEA 1.0.0. `libtea.client.SUPPORTED_SPEC_VERSIONS` lists the supported versions, highest first. + +On a 0.4.0 server: + +- the page token is the next result offset as a string, and `total_results`, `page_start_index`, `page_size` and `timestamp` are filled in (they are `None` on 1.x); +- `page_size` may be up to 10000 (up to 100 on 1.x); +- MD5 checksums are accepted; +- fields that only 1.0.0 requires may be missing; +- discovery by PURL and `download_artifact_content` are unavailable and raise `TeaValidationError`. + ### Discovery ```python from libtea import TeaClient -# Discover sbomify products via TEI -with TeaClient.from_well_known("trust.sbomify.com") as client: +with TeaClient.from_well_known("example.com") as client: + # Discover product releases via TEI results = client.discover( - "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ) for info in results: print(info.product_release_uuid, info.servers) + + # Discover by PURL in this server's inventory (TEA 1.x servers only) + results = client.discover(purl="pkg:github/sbomify/sbomify") ``` +`discover` takes exactly one of `tei` and `purl`. The value is sent to the server verbatim, so pass a TEI in the form the server speaks: `tei:////` for TEA 1.x, the legacy URN form for 0.4.0 servers (see [Upgrading](#upgrading)). On a 1.x server an unknown TEI is a 404 (`TeaNotFoundError`). + Low-level discovery functions are also available: ```python from libtea.discovery import parse_tei, fetch_well_known, select_endpoint -# Parse a TEI URN +# Parse a TEI tei_type, domain, identifier = parse_tei( - "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" -) + "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" +) # ("uuid", "example.com", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1") # Fetch and select an endpoint manually -well_known = fetch_well_known("trust.sbomify.com") -endpoint = select_endpoint(well_known, "0.4.0") +well_known = fetch_well_known(domain) +endpoint = select_endpoint(well_known, "1.0.0") print(endpoint.url, endpoint.priority) ``` -Supported TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi`. +Known TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi` (`TeiType`). In the `tei://` form `parse_tei` accepts any lowercase type, so types added to the spec's registry keep working. A `purl` identifier in that form is the unpadded Base64URL encoding of the PURL; libtea passes it through unchanged. ### Search @@ -136,14 +165,21 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: for product in results.results: print(product.name, product.uuid) - # Search product releases (with pagination) - releases = client.search_product_releases( - "PURL", "pkg:github/sbomify/sbomify", - page_offset=0, page_size=100, - ) - print(releases.total_results) + # Search product releases, following the page token + page = client.search_product_releases("PURL", "pkg:github/sbomify/sbomify", page_size=100) + while True: + for release in page.results: + print(release.version) + if not page.has_next: + break + page = client.search_product_releases( + "PURL", "pkg:github/sbomify/sbomify", + page_token=page.next_page_token, page_size=100, + ) ``` +Each page has `results`, `has_next` and an opaque `next_page_token`, which you pass back as `page_token`. `page_size` is at most 100 on TEA 1.x servers. The 0.4.0 offset fields (`total_results` and the like) are `None` on 1.x; see [Spec versions](#spec-versions). + ### Listing (unfiltered) ```python @@ -155,7 +191,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: # List all product releases releases = client.list_product_releases(page_size=50) - print(releases.total_results) + print(len(releases.results), releases.has_next) ``` ### Auto-paginated iterators @@ -177,6 +213,8 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: print(release.version, release.created_date) ``` +The iterators follow `next_page_token` and raise `TeaServerError` if a server repeats a token or keeps paging past 10,000 pages. + ### Bulk parallel fetch Fetch multiple resources by UUID in parallel: @@ -204,6 +242,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: # Single product release pr = client.get_product_release("release-uuid") print(pr.version, pr.components) + print(len(pr.latest_collection.artifacts)) # latest_collection is None on 0.4.0 servers # Product release collections latest = client.get_product_release_collection_latest("release-uuid") @@ -216,7 +255,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: ```python with TeaClient.from_well_known("trust.sbomify.com") as client: component = client.get_component("component-uuid") - releases = client.get_component_releases("component-uuid") + releases = client.get_component_releases("component-uuid") # a list; every page is fetched on 1.x # Get a component release with its latest collection cr = client.get_component_release("release-uuid") @@ -251,13 +290,25 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: path = client.download_artifact( fmt.url, Path("sbom.json"), - verify_checksums=fmt.checksums, + verify_checksums=list(fmt.checksums), ) ``` -Supported checksum algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. BLAKE3 is recognized in the model but not verifiable (Python's `hashlib` has no BLAKE3 support — a clear error is raised). +A format without a `url` is hosted by the TEA server itself (TEA 1.x). Fetch it through the server's download endpoint: -Artifact downloads use a separate unauthenticated HTTP session so the bearer token is never leaked to third-party hosts (CDNs, Maven Central, etc.). On checksum mismatch, the downloaded file is automatically deleted. +```python + path = client.download_artifact_content( + artifact.uuid, + Path("sbom.json"), + media_type=fmt.media_type, + version=artifact.version, # omit for the latest version + verify_checksums=list(fmt.checksums), + ) +``` + +Supported checksum algorithms: SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512, and MD5 on 0.4.0 servers (TEA 1.0.0 dropped MD5, so a 1.x response that carries it is rejected). BLAKE3 is recognized in the model but not verifiable (Python's `hashlib` has no BLAKE3 support — a clear error is raised). + +`download_artifact` uses a separate unauthenticated HTTP session so the bearer token is never leaked to third-party hosts (CDNs, Maven Central, etc.). `download_artifact_content` sends the token only on its first request, to the API base, and never on a redirect. On checksum mismatch, the downloaded file is automatically deleted. ### Common Lifecycle Enumeration (CLE) @@ -292,7 +343,7 @@ Enable optional TTL-based caching to avoid repeated API calls: ```python # Cache GET responses for 5 minutes -client = TeaClient("https://trust.sbomify.com/tea/v0.4.0", cache_ttl=300) +client = TeaClient("https://tea.example.com/tea/v1.0.0", cache_ttl=300) product = client.get_product("uuid") # HTTP call product = client.get_product("uuid") # served from cache @@ -313,7 +364,7 @@ client = TeaClient.from_well_known( ) ``` -Scheme validation and cloud metadata endpoint blocking are always enforced. +Scheme validation and cloud metadata endpoint blocking are always enforced. If the API base itself is a private address and its download endpoint redirects to its own host, `download_artifact_content` checks that redirect too, so it needs `allow_private_ips=True`. ### Testing with TeaClientProtocol @@ -351,24 +402,27 @@ All commands also accept connection options: `--base-url`, `--domain`, `--token` ### Discover ```bash -# Discover sbomify product releases via TEI -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +# Discover product releases via TEI (the TEI's domain is used to find the server) +tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # UUIDs only (for scripting) -tea-cli discover -q "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli discover -q "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # JSON output -tea-cli --json discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli --json discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + +# Resolve a PURL on a TEA 1.x server (needs --base-url) +tea-cli discover --purl "pkg:github/sbomify/sbomify" --base-url https://tea.example.com/tea/v1.0.0 ``` ### Inspect (full flow) ```bash # TEI -> discovery -> releases -> components -> artifacts in one shot -tea-cli inspect "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli inspect "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # Limit component resolution -tea-cli inspect --max-components 10 "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli inspect --max-components 10 "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` ### Search @@ -381,6 +435,10 @@ tea-cli search-products --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ # Search product releases tea-cli search-releases --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ --domain trust.sbomify.com --page-size 50 + +# Next page: pass the token printed under the results (an offset on TEA 0.x servers) +tea-cli search-releases --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ + --domain trust.sbomify.com --page-size 50 --page-token "" ``` ### Products and releases @@ -428,6 +486,9 @@ tea-cli get-artifact --domain trust.sbomify.com tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ --checksum "SHA-256:abc123..." \ --domain trust.sbomify.com + +# Download every artifact of a TEI (formats without a url are fetched from the TEA 1.x server) +tea-cli download "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ./sboms/ ``` ### Lifecycle (CLE) @@ -447,7 +508,7 @@ tea-cli get-cle --entity component-release --domain trust.sbomify.com tea-cli conformance --base-url https://tea.example.com/v1 # With TEI for discovery-driven testing -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # JSON output for CI pipelines tea-cli conformance --base-url https://tea.example.com/v1 --json @@ -485,7 +546,7 @@ from libtea.conformance import run_conformance result = run_conformance( "https://tea.example.com/v1", - tei="urn:tei:purl:example.com:pkg:pypi/mylib", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", ) print(f"{result.passed} passed, {result.failed} failed, {result.skipped} skipped") ``` @@ -493,14 +554,14 @@ print(f"{result.passed} passed, {result.failed} failed, {result.skipped} skipped ### CLI ```bash -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" tea-cli conformance --base-url https://tea.example.com/v1 --json ``` ### pytest plugin ```bash -pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." +pytest --tea-base-url https://tea.example.com/v1 --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` The plugin generates one test per conformance check. See the [conformance docs](docs/conformance.md) for the full check reference. @@ -522,16 +583,20 @@ tea_datetime_serializer(datetime.now(timezone.utc)) All exceptions inherit from `TeaError`: ```python -from libtea.exceptions import TeaError, TeaNotFoundError, TeaChecksumError +from libtea.exceptions import TeaError, TeaNotFoundError, TeaRequestError try: product = client.get_product("unknown-uuid") except TeaNotFoundError as exc: - print(exc.error_type) # "OBJECT_UNKNOWN" or "OBJECT_NOT_SHAREABLE" + print(exc.error_type) # "OBJECT_UNKNOWN" ("OBJECT_NOT_SHAREABLE" on 0.4.0 servers) +except TeaRequestError as exc: + print(exc.error_type) # e.g. "INVALID_PAGE_TOKEN"; None if the body has no TEA error except TeaError: print("Something went wrong") ``` +`error_type` is the `error` value of the server's TEA error body. `libtea.ErrorType` lists the known values (`OBJECT_UNKNOWN`, `NOT_IMPLEMENTED`, `NO_ACCEPTABLE_FORMAT`, `SIGNATURE_NOT_FOUND`, `INVALID_REQUEST`, `INVALID_PAGE_TOKEN`, and `OBJECT_NOT_SHAREABLE` on 0.4.0); servers may send others, which pass through as plain strings. Compare with `==`. + Exception hierarchy: | Exception | When | @@ -539,7 +604,7 @@ Exception hierarchy: | `TeaConnectionError` | Network failure or timeout | | `TeaAuthenticationError` | HTTP 401/403 | | `TeaNotFoundError` | HTTP 404 (`.error_type` has the TEA error code) | -| `TeaRequestError` | Other HTTP 4xx | +| `TeaRequestError` | Other HTTP 4xx (`.error_type` has the TEA error code) | | `TeaServerError` | HTTP 5xx | | `TeaDiscoveryError` | Invalid TEI, `.well-known` failure, or no compatible endpoint | | `TeaChecksumError` | Checksum mismatch (`.algorithm`, `.expected`, `.actual`) | @@ -548,6 +613,56 @@ Exception hierarchy: Using a bearer token over plaintext HTTP raises `ValueError` immediately — HTTPS is required for authenticated requests. +## Upgrading + +libtea now targets TEA 1.0.0 and still talks to 0.4.0 servers. These Python API changes can break code written against libtea 0.5.1. + +**Spec versions** + +- A base URL without a `/v` suffix (and no `spec_version=`) is now TEA 1.0.0, not 0.4.0. `TEA_SPEC_VERSION` is `"1.0.0"`. To keep talking to a 0.4.0 server, pass `spec_version="0.4.0"` or end the base URL in `/v0.4.0`. +- `TeaClient.from_well_known(version=None)` now negotiates: it prefers 1.0.0, falls back to 0.4.0, and fails over across both. It used to default to `"0.4.0"`. Pass `version="0.4.0"` to pin the old behaviour. The API path uses the version spelling exactly as the server advertises it (for example `/v1.0.0+build.5`). +- `TeaClient` gains `spec_version` and `is_legacy`. `TeaClientProtocol` gains them too, plus `download_artifact_content` and the new `discover` signature, so test doubles that implement the protocol need updating. + +**Discovery and TEIs** + +- TEIs are now `tei:////`. `parse_tei` and the CLI still accept the legacy `urn:tei:::` form, and `TeaClient.discover` sends whatever TEI you give it verbatim; use the legacy form with 0.4.0 servers. +- `discover(tei=None, *, purl=None)` takes exactly one of the two, otherwise `TeaValidationError`. PURL lookup needs a 1.x server. On a 1.x server an empty discovery array also raises `TeaValidationError`; an unknown TEI is a 404. + +**Pagination** + +- `page_offset: int` is replaced by `page_token: str | None` on `list_*`, `search_*` and `get_product_releases`; the CLI's `--page-offset` is now `--page-token`. On 0.4.0 servers the token is the next offset as a string (`"100"`). +- `Paginated*Response` has `has_next` and `next_page_token`. `timestamp`, `page_start_index`, `page_size` and `total_results` are now optional and are `None` on 1.x servers, so code that reads them must handle `None`. `PaginatedCollectionResponse` is new. +- `page_size` is at most 100 on 1.x servers (10000 on 0.4.0), otherwise `TeaValidationError`. +- The `iter_*` iterators follow tokens and raise `TeaServerError` when a token repeats or after 10,000 pages. On 1.x servers `get_component_releases` and `get_*_release_collections` still return a list, but they now fetch every page. + +**Models** + +- `Collection.date` is now `Collection.created_date`. A 0.4.0 `date` is mapped to it. +- `get_product_release` returns `ProductReleaseWithCollection`, a `ProductRelease` subclass with `latest_collection` (`None` on 0.4.0 servers). Because of that, JSON output for a product release (`tea-cli --json get-release`, `tea-cli inspect`) now includes `latestCollection`: the collection on 1.x servers, `null` on 0.4.0 servers. +- `ComponentReleaseWithCollection` still exposes `.release` and `.latest_collection`, and now accepts the flattened 1.0.0 wire shape. +- Fields that both spec versions require are no longer optional: `Checksum.algorithm_type` and `algorithm_value`, `Artifact.uuid`, `type` and `formats`, and `ReleaseDistribution.distribution_id`. Building one without them now fails. +- Fields that only 1.0.0 requires (for example `Release.component`, `ProductRelease.product`, `Artifact.version` and `created_date`, `ArtifactFormat.media_type`, `Identifier.id_type` and `id_value`, and the `Collection` fields) keep their optional types, but a 1.x response that omits one raises `TeaValidationError`. 0.4.0 responses may omit them. +- Removed: `ReleaseDistribution.distribution_type`, `Artifact.distribution_types`, and the `mimeType` alias for `ArtifactFormat.media_type`. +- `ComplianceDocumentType.FEDRAMP` is now `"FEDRAMP"` (was `"FedRAMP"`), and `EU_DECLARATION_OF_CONFORMITY` is new. + +**Checksums** + +MD5 is rejected in responses from 1.x servers and still accepted from 0.4.0 servers (and by the CLI's `--checksum`). To build an MD5 `Checksum` yourself, validate it in the legacy context; a plain `Checksum(...)` follows the 1.0.0 rules: + +```python +from libtea import Checksum + +md5 = Checksum.model_validate( + {"algType": "MD5", "algValue": "d41d8cd98f00b204e9800998ecf8427e"}, + context={"tea_legacy": True}, +) +``` + +**Errors** + +- `ErrorResponse.error` is a `str` (was `ErrorType`); compare it with `==`. `ErrorResponse.message` is new, and `ErrorType` has the 1.0.0 values. +- `TeaRequestError` now has `.error_type`, like `TeaNotFoundError`. libtea reads at most 4 KiB of a 4xx body to find it, and cuts the message snippet at 200 characters. + ## Requirements - Python >= 3.11 @@ -561,8 +676,9 @@ Optional: ## Not yet supported -- Publisher API (spec is consumer-only in v0.4.0) +- Publisher API (blocked on the TEA spec) - Async client +- Some TEA 1.0.0 consumer features: the `/token` exchange, `ETag` revalidation, signature downloads, `sortField`/`sortOrder` and following `servers[].rootUrl`. See [docs/FUTURE.md](docs/FUTURE.md) ## Development diff --git a/docs/FUTURE.md b/docs/FUTURE.md index eeb579a..51ba295 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -20,7 +20,7 @@ Items that depend on external factors or are deferred indefinitely. These are ** **TEA spec tracking:** [CycloneDX/transparency-exchange-api](https://github.com/CycloneDX/transparency-exchange-api) -**Additional note for Publisher API:** When serializing models back to the server, use `model_dump(mode="json", by_alias=True, exclude_none=True)` and explicitly exclude deprecated fields (`distribution_type`, `distribution_types`) — `exclude_none` alone won't help if they were populated from an older server response. Serialization should also be gated by the negotiated spec version so deprecated fields are only sent to servers that expect them. +**Additional note for Publisher API:** When serializing models back to the server, use `model_dump(mode="json", by_alias=True, exclude_none=True)`. Serialization should also be gated by the negotiated spec version (`TeaClient.is_legacy`) so 0.4.0-only wire shapes (for example `Collection.date` instead of `createdDate`, offset pagination fields, MD5 checksums) are only sent to servers that expect them. **Action:** When the Publisher API reaches beta (stable naming, stable schema), create a versioned design doc in `docs/plans/` and schedule for the next minor release. @@ -39,14 +39,30 @@ Items that depend on external factors or are deferred indefinitely. These are ** --- -## Spec v0.4.0 Follow-ups +## TEA 1.0.0 Deferred Items -**Blocked on:** Nothing — these are low-priority items from the v0.4.0 alignment review (PR #11). +**Blocked on:** Nothing — consumer-side parts of the TEA 1.0.0 spec that the 1.0.0 alignment left out on purpose. None is needed to read from a 1.x server. + +| Item | Notes | +|------|-------| +| `/token` client-credentials exchange | `POST /token` with the `client_credentials` grant (see `auth/readme.md`). Today a caller supplies an already-issued bearer token or basic-auth credentials. | +| `ETag` / `If-None-Match` / `304` | Conditional requests and revalidation for the artifact download endpoints. Downloads currently always fetch the full body. | +| Signature download endpoints | `/artifact/{uuid}/latest/signature/download` and `/artifact/{uuid}/{artifactVersion}/signature/download`. `signatureUrl` on a format is exposed in the models but not fetched. | +| `sortField` / `sortOrder` | Sorting query parameters on the list and search endpoints. The client's list and search methods do not send them. | +| Routing follow-up requests to the discovery `servers[].rootUrl` | `discover()` returns the servers that host a product release, but later calls keep using the client's own API base. Honouring `rootUrl` + `/v` per release needs a client per server and token scoping (a token must go only to its own API base). | +| `purl` → `tei:///purl/` builder | Helper that turns a PURL into the unpadded Base64URL TEI identifier. `parse_tei` already accepts such TEIs and passes the identifier through unchanged; callers must encode it themselves. | +| Lowercase-hex checksum conformance check | The spec wants lowercase hexadecimal checksum values. Verification already lowercases both sides; a conformance check should flag servers that send uppercase. | + +**Action:** Schedule individual items when a consumer needs them; each is independent. + +--- + +## Conformance Follow-ups + +**Blocked on:** Nothing — low-priority items from earlier alignment reviews. | Item | Notes | |------|-------| -| Deprecation warnings for legacy fields | `distribution_type` and `distribution_types` are spec-deprecated in v0.4.0. Consider adding `DeprecationWarning` via `@model_validator(mode="after")` when only old fields are populated. | -| Conformance checks for v0.4.0 required fields | `Artifact.uuid` and `ReleaseDistribution.distribution_id` are now required per spec. Add conformance checks that verify presence on v0.4.0 servers (gated behind version check). Note: `Artifact.formats` check is implemented as `check_artifact_formats_required` (WARN status). | | Component conformance checks | Add conformance checks for `/components` and `/componentReleases` endpoints (list, search) to match the product-level checks. | -**Action:** Address when a v0.4.0 server is available for testing. +**Action:** Address opportunistically. diff --git a/docs/cli.md b/docs/cli.md index 75977d2..43fcd76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,7 +10,7 @@ tea-cli [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] ## Description -`tea-cli` is a command-line interface for the Transparency Exchange API (TEA) v0.4.0. It discovers, searches, and retrieves software transparency artifacts (SBOMs, VEX, build metadata) from TEA-compliant servers. +`tea-cli` is a command-line interface for the Transparency Exchange API (TEA) 1.0.0, with compatibility for 0.4.0 servers. It discovers, searches, and retrieves software transparency artifacts (SBOMs, VEX, build metadata) from TEA-compliant servers. Output is rich-formatted by default (tables, panels) for interactive use. Use `--json` for machine-readable JSON output suitable for piping. @@ -41,8 +41,8 @@ Every command accepts the following options for server selection and authenticat | Option | Description | |--------|-------------| -| `--base-url` *URL* | TEA server base URL (e.g. `https://trust.sbomify.com/tea/v0.4.0`). Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | -| `--domain` *DOMAIN* | Discover server from the domain's `.well-known/tea` endpoint. The domain can also be auto-extracted from a TEI URN argument. | +| `--base-url` *URL* | TEA server base URL (e.g. `https://tea.example.com/tea/v1.0.0`). A `/v` suffix selects the spec version (`v0.4.0` for a TEA 0.4.0 server); without one, TEA 1.0.0 is assumed. Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | +| `--domain` *DOMAIN* | Discover server from the domain's `.well-known/tea` endpoint. TEA 1.0.0 is preferred, falling back to 0.4.0. The domain can also be auto-extracted from a TEI argument. | | `--token` *TOKEN* | Bearer token for authentication. Prefer the `TEA_TOKEN` environment variable to avoid exposing the token in shell history. | | `--auth` *USER:PASSWORD* | HTTP basic authentication credentials. Prefer the `TEA_AUTH` environment variable to avoid exposing credentials in shell history. Mutually exclusive with `--token`. | | `--timeout` *SECONDS* | Request timeout in seconds (default: 30). | @@ -54,29 +54,31 @@ Every command accepts the following options for server selection and authenticat ### discover -Resolve a TEI URN to product release UUID(s). +Resolve a TEI, or a PURL, to product release UUID(s). Give exactly one of `TEI` and `--purl`. ``` tea-cli discover [--quiet | -q] TEI +tea-cli discover [--quiet | -q] --purl PURL --base-url URL ``` **Arguments:** | Argument | Description | |----------|-------------| -| *TEI* | A TEI URN (e.g. `urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify`). The domain is auto-extracted for server discovery when `--base-url` and `--domain` are omitted. | +| *TEI* | A TEI (e.g. `tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1`). The domain is auto-extracted for server discovery when `--base-url` and `--domain` are omitted. TEA 0.4.0 servers expect the legacy URN form, which is also accepted (see Upgrading in the README). | **Options:** | Option | Description | |--------|-------------| +| `--purl` *PURL* | Resolve a Package URL in the inventory of the server given by `--base-url` (required), instead of a TEI. Needs a TEA 1.x server. | | `--quiet`, `-q` | Output only UUIDs, one per line. Useful for scripting. | --- ### inspect -Full flow: TEI -> discovery -> product releases -> components -> artifacts. Resolves a TEI and fetches the full object graph in one shot. +Full flow: TEI -> discovery -> product releases -> components -> artifacts. Resolves a TEI (`tei://` or the legacy URN form) and fetches the full object graph in one shot. ``` tea-cli inspect [--max-components N] TEI @@ -162,6 +164,8 @@ tea-cli get-release [--component] UUID |--------|-------------| | `--component` | Get a component release instead of a product release. | +With `--json`, a product release also carries `latestCollection`: its latest collection on TEA 1.x servers, `null` on 0.4.0 servers. + --- ### get-component @@ -270,25 +274,33 @@ tea-cli get-artifact-version UUID VERSION ### download -Download an artifact file with optional checksum verification. +Download an artifact file with optional checksum verification, or every artifact of a TEI. ``` tea-cli download URL DEST [--checksum ALG:VALUE]... [--max-download-bytes N] +tea-cli download TEI [DEST_DIR] [--max-download-bytes N] [-y] [-n] [-q] ``` **Arguments:** | Argument | Description | |----------|-------------| -| *URL* | Artifact download URL. | -| *DEST* | Local file path to save the downloaded artifact. | +| *URL* | Artifact download URL (URL mode). | +| *TEI* | A TEI (`tei://` or the legacy URN form). Resolves it and downloads every artifact format of the latest collection into `DEST_DIR` (TEI mode). Checksums come from the server metadata. | +| *DEST* | Local file path to save the downloaded artifact (URL mode). | +| *DEST_DIR* | Directory for the downloaded artifacts (TEI mode). If omitted, you are asked to confirm downloading into the current directory; `-y` skips the prompt. | + +On a TEA 1.x server, a format without a `url` is hosted by the server itself and is fetched through its download endpoint; the bearer token is sent only to the API server, never on a redirect. **Options:** | Option | Description | |--------|-------------| -| `--checksum` *ALG:VALUE* | Checksum to verify, as `ALGORITHM:HEX_VALUE`. Can be specified multiple times. Supported algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. | +| `--checksum` *ALG:VALUE* | Checksum to verify, as `ALGORITHM:HEX_VALUE` (URL mode only). Can be specified multiple times. Supported algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. MD5 is accepted here because you supply the hash. | | `--max-download-bytes` *N* | Maximum download size in bytes. The download is aborted if the response exceeds this limit. | +| `-y`, `--yes` | TEI mode: skip the confirmation prompt for downloading into the current directory. | +| `-n`, `--dry-run` | TEI mode: show what would be downloaded without downloading. | +| `-q`, `--quiet` | Suppress progress output (errors still shown). | --- @@ -319,7 +331,7 @@ tea-cli conformance --base-url URL [OPTIONS] | Option | Description | |--------|-------------| | `--base-url` *URL* | TEA server base URL (required). Can also be set via `TEA_BASE_URL`. | -| `--tei` *TEI* | TEI URN for discovery-driven testing. Seeds the check context with a product release UUID discovered from the TEI. | +| `--tei` *TEI* | TEI for discovery-driven testing. Seeds the check context with a product release UUID discovered from the TEI. | | `--product-uuid` *UUID* | Explicit product UUID for direct testing. | | `--release-uuid` *UUID* | Explicit product release UUID. | | `--component-uuid` *UUID* | Explicit component UUID. | @@ -362,13 +374,20 @@ Using credentials over plaintext HTTP raises an error. Use `--use-http` only for Discover sbomify product releases: ```bash -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" +``` + +Resolve a PURL on a TEA 1.x server: + +```bash +tea-cli discover --purl "pkg:github/sbomify/sbomify" \ + --base-url https://tea.example.com/tea/v1.0.0 ``` Full inspection with JSON output: ```bash -tea-cli --json inspect "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli --json inspect "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` Search for a product by PURL: @@ -398,18 +417,18 @@ tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ Pipe UUIDs to another tool: ```bash -tea-cli discover -q "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" | \ - xargs -I {} tea-cli --json get-release {} --domain trust.sbomify.com +tea-cli discover -q "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" | \ + xargs -I {} tea-cli --json get-release {} --domain example.com ``` Verbose logging to diagnose issues: ```bash # libtea debug logs only (no urllib3 noise) -tea-cli -v discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli -v discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # Full HTTP firehose -tea-cli -d discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli -d discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` Local development with private IPs: @@ -417,15 +436,15 @@ Local development with private IPs: ```bash # Allow downloads from a local TEA server tea-cli download "http://192.168.1.100:8080/sbom.json" ./sbom.json \ - --allow-private-ips --base-url "http://192.168.1.100:8080/tea/v0.4.0" + --allow-private-ips --base-url "http://192.168.1.100:8080/tea/v1.0.0" ``` Using environment variables to avoid repeating credentials: ```bash export TEA_TOKEN="your-bearer-token" -export TEA_BASE_URL="https://trust.sbomify.com/tea/v0.4.0" -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +export TEA_BASE_URL="https://tea.example.com/tea/v1.0.0" +tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" tea-cli get-product ``` diff --git a/docs/conformance.md b/docs/conformance.md index 072882d..a36c8a9 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -19,7 +19,7 @@ from libtea.conformance import run_conformance result = run_conformance( "https://tea.example.com/v1", - tei="urn:tei:purl:example.com:pkg:pypi/mylib", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", token="your-bearer-token", timeout=30.0, ) @@ -38,7 +38,7 @@ for check in result.checks: | Parameter | Type | Description | |-----------|------|-------------| | `base_url` | `str` | TEA server base URL (required) | -| `tei` | `str \| None` | TEI URN for discovery-driven testing | +| `tei` | `str \| None` | TEI for discovery-driven testing | | `product_uuid` | `str \| None` | Explicit product UUID | | `product_release_uuid` | `str \| None` | Explicit product release UUID | | `component_uuid` | `str \| None` | Explicit component UUID | @@ -49,6 +49,8 @@ for check in result.checks: | `timeout` | `float` | Request timeout in seconds (default: 30) | | `allow_private_ips` | `bool` | Allow private IPs in downloads (default: False) | +The spec version under test comes from `base_url`: a `/v` suffix such as `/v0.4.0` selects that version, otherwise TEA 1.0.0 is assumed. Checks that differ between versions adapt to it (see the check reference). + **Returns:** `ConformanceResult` with `.passed`, `.failed`, `.warned`, `.skipped` counts and `.checks` list. ### CLI @@ -58,7 +60,7 @@ for check in result.checks: tea-cli conformance --base-url https://tea.example.com/v1 # With TEI for discovery-driven testing -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # With explicit UUIDs tea-cli conformance --base-url https://tea.example.com/v1 \ @@ -83,7 +85,7 @@ The conformance suite registers as a pytest plugin via the `pytest11` entry poin ```bash # Run conformance checks as pytest tests -pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." +pytest --tea-base-url https://tea.example.com/v1 --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # With authentication pytest --tea-base-url https://tea.example.com/v1 --tea-token "$TEA_TOKEN" @@ -97,7 +99,7 @@ pytest --tea-base-url https://tea.example.com/v1 -v | Option | Description | |--------|-------------| | `--tea-base-url` | TEA server base URL (required to enable conformance tests) | -| `--tea-tei` | TEI URN for discovery-driven testing | +| `--tea-tei` | TEI for discovery-driven testing | | `--tea-token` | Bearer token | | `--tea-product-uuid` | Explicit product UUID | | `--tea-release-uuid` | Explicit product release UUID | diff --git a/src/libtea/__init__.py b/src/libtea/__init__.py index 7e56d72..3087a78 100644 --- a/src/libtea/__init__.py +++ b/src/libtea/__init__.py @@ -5,7 +5,7 @@ from libtea import TeaClient with TeaClient("https://tea.example.com/v1", token="...") as client: - results = client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") + results = client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") Or auto-discover the server from a domain's ``.well-known/tea``:: diff --git a/src/libtea/cli.py b/src/libtea/cli.py index b1c7dc6..20334ac 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -87,9 +87,7 @@ def shared_options(fn): # type: ignore[no-untyped-def] click.option("--no-input", is_flag=True, help="Never prompt for input (for scripts and CI)"), ] if not has_positional_tei: - options.append( - click.option("--tei", "tei_urn", default=None, help="TEI URN for base-url/domain auto-discovery") - ) + options.append(click.option("--tei", "tei_urn", default=None, help="TEI for base-url/domain auto-discovery")) options.extend( [ click.option("--port", type=int, default=None, help="Port for well-known resolution"), @@ -183,7 +181,7 @@ def _parse_basic_auth(auth: str | None) -> tuple[str, str] | None: def _domain_from_tei(tei: str | None) -> str | None: - """Extract the domain component from a TEI (``tei://`` or ``urn:tei:``) for auto-discovery. + """Extract the domain component from a TEI (``tei://`` URI or legacy URN form) for auto-discovery. Returns ``None`` if ``tei`` is falsy or not a valid TEI. """ @@ -211,7 +209,7 @@ def _build_client( """Build a TeaClient from CLI options. When neither --base-url nor --domain is provided, the domain is extracted - from the TEI URN (if given) and used for .well-known/tea discovery. + from the TEI (if given) and used for .well-known/tea discovery. """ # Fall back to --tei from shared_options (stored in ctx.obj) when tei is not provided directly if not tei: @@ -360,8 +358,8 @@ def _error(message: str) -> NoReturn: \b Quick start: - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' - tea-cli download 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' ./sboms/ + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' + tea-cli download 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' ./sboms/ \b Environment variables: @@ -453,8 +451,8 @@ def discover( \b Examples: - tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --domain example.com - tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --quiet + tea-cli discover 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --domain example.com + tea-cli discover 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --quiet tea-cli discover --purl 'pkg:pypi/requests@2.31.0' --base-url https://tea.example.com/tea/v1.0.0 """ if (tei is None) == (purl is None): @@ -531,7 +529,7 @@ def search_releases( \b Examples: tea-cli search-releases --id-type PURL --id-value 'pkg:pypi/requests' --domain example.com - tea-cli search-releases --id-type TEI --id-value 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --json + tea-cli search-releases --id-type TEI --id-value 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips @@ -864,7 +862,7 @@ def search_component_releases( \b Examples: tea-cli search-component-releases --id-type PURL --id-value 'pkg:pypi/requests' --domain example.com - tea-cli search-component-releases --id-type TEI --id-value 'urn:tei:...' --json + tea-cli search-component-releases --id-type TEI --id-value 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips @@ -1149,7 +1147,7 @@ def download( \b Examples: - tea-cli download 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' ./sboms/ + tea-cli download 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' ./sboms/ tea-cli download https://tea.example.com/artifacts/abc/download output.json --checksum SHA-256:abcdef... """ ctx = click.get_current_context() @@ -1244,8 +1242,8 @@ def inspect( \b Examples: - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --domain example.com - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --json + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --domain example.com + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips @@ -1343,7 +1341,7 @@ def conformance( \b Examples: - tea-cli conformance --base-url https://tea.example.com --tei 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' + tea-cli conformance --base-url https://tea.example.com --tei 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' tea-cli conformance --base-url https://tea.example.com --verbose """ ctx.ensure_object(dict) diff --git a/src/libtea/client.py b/src/libtea/client.py index 5eef3f3..9f33e13 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -257,8 +257,8 @@ def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[D client already knows and is only available on TEA 1.x servers. Args: - tei: TEI string, sent verbatim (e.g. ``tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1`` - or ``urn:tei:purl:example.com:pkg:pypi/lib@1.0``). + tei: TEI string, sent verbatim (e.g. ``tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1``). + Use the form the server speaks: the ``tei://`` URI on TEA 1.x, the legacy URN on 0.4.0. purl: Package URL, sent verbatim (e.g. ``pkg:maven/org.apache.logging.log4j/log4j-core@2.24.3``). Returns: @@ -746,7 +746,9 @@ def download_artifact_content( Fetches ``/artifact/{uuid}/{version}/download?mediaType=...`` (``latest`` when ``version`` is omitted) with the client's credentials. A redirect is followed - without credentials, so the token is only ever sent to the API base. + without credentials, so the token is only ever sent to the API base. When the API base is a + private address and its download endpoint redirects to its own host, the redirect hop is + SSRF-checked, so it needs ``allow_private_ips=True``. Args: uuid: Artifact UUID. diff --git a/src/libtea/models.py b/src/libtea/models.py index b0aef92..758fbcf 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -99,10 +99,11 @@ class ComplianceDocumentType(StrEnum): class TeiType(StrEnum): - """TEI URN scheme types per TEA discovery specification. + """TEI types per TEA discovery specification. - These are the valid ```` values in a TEI URN - (``urn:tei:::``). + These are the ```` values of a TEI. The 0.4.0 URN form + (``urn:tei:::``) accepts only these; the 1.0.0 URI form + (``tei:////``) also accepts other lowercase types. """ UUID = "uuid" From 8163d58128e8f1b79f479b954cddc30dca7bc1d7 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 20:51:28 +0500 Subject: [PATCH 21/29] docs: restore sbomify 0.4.0 examples, fix runnable README snippets --- CLAUDE.md | 2 +- README.md | 49 ++++++++++++++++++++++++++++++++++--------------- docs/FUTURE.md | 2 +- docs/cli.md | 6 +++--- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cc5dcc..e9a8b0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ uv build # Build wheel and sdist - **Line length**: 120, ruff rules: E, F, I - **Models**: Pydantic v2 with `frozen=True`, `extra="ignore"`, `alias_generator=to_camel` - **HTTP mocking**: `responses` library (not `unittest.mock` for HTTP) -- **Coverage**: Branch coverage enabled, target ~97% +- **Coverage**: Branch coverage enabled, target ~95% ## Architecture diff --git a/README.md b/README.md index 63c43c5..c56c17e 100644 --- a/README.md +++ b/README.md @@ -78,20 +78,23 @@ with TeaClient.from_well_known("example.com") as client: print(pr.version, pr.product_name) ``` -Or connect directly to a known endpoint: +Or connect directly to a known endpoint. The `/v` suffix selects the spec version; `trust.sbomify.com` is a working TEA 0.4.0 server: ```python client = TeaClient( - base_url="https://tea.example.com/tea/v1.0.0", # the /v suffix selects the spec version + base_url="https://trust.sbomify.com/tea/v0.4.0", timeout=30.0, ) + +# A TEA 1.0.0 server +client = TeaClient(base_url="https://tea.example.com/tea/v1.0.0", timeout=30.0) ``` `from_well_known` negotiates the spec version for you (TEA 1.0.0 first, then 0.4.0). You can also pin a version and set the timeout: ```python client = TeaClient.from_well_known( - "example.com", + "trust.sbomify.com", timeout=15.0, version="0.4.0", # default: None, i.e. negotiate 1.0.0 first, then 0.4.0 ) @@ -104,7 +107,7 @@ client = TeaClient.from_well_known( libtea speaks TEA 1.0.0 and 0.4.0. Each client knows which one it talks to, and its behaviour follows that version, never the shape of a response: ```python -client = TeaClient("https://tea.example.com/tea/v0.4.0") +client = TeaClient("https://trust.sbomify.com/tea/v0.4.0") client.spec_version # "0.4.0" client.is_legacy # True for 0.4.0, False for 1.x ``` @@ -154,7 +157,7 @@ endpoint = select_endpoint(well_known, "1.0.0") print(endpoint.url, endpoint.priority) ``` -Known TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi` (`TeiType`). In the `tei://` form `parse_tei` accepts any lowercase type, so types added to the spec's registry keep working. A `purl` identifier in that form is the unpadded Base64URL encoding of the PURL; libtea passes it through unchanged. +Known TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi` (`TeiType`). In the `tei://` form `parse_tei` accepts any type matching `[a-z][a-z0-9]*` (a lowercase letter, then lowercase letters or digits), so types added to the spec's registry keep working. A `purl` identifier in that form is the unpadded Base64URL encoding of the PURL; libtea passes it through unchanged. ### Search @@ -297,13 +300,22 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: A format without a `url` is hosted by the TEA server itself (TEA 1.x). Fetch it through the server's download endpoint: ```python - path = client.download_artifact_content( - artifact.uuid, - Path("sbom.json"), - media_type=fmt.media_type, - version=artifact.version, # omit for the latest version - verify_checksums=list(fmt.checksums), - ) +from pathlib import Path + +from libtea import TeaClient + +with TeaClient("https://tea.example.com/tea/v1.0.0") as client: + artifact = client.get_artifact("artifact-uuid") + fmt = artifact.formats[0] + + if fmt.url is None: # hosted by the TEA server itself + path = client.download_artifact_content( + artifact.uuid, + Path("sbom.json"), + media_type=fmt.media_type, # required on TEA 1.x + version=artifact.version, # omit for the latest version + verify_checksums=list(fmt.checksums), + ) ``` Supported checksum algorithms: SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512, and MD5 on 0.4.0 servers (TEA 1.0.0 dropped MD5, so a 1.x response that carries it is rejected). BLAKE3 is recognized in the model but not verifiable (Python's `hashlib` has no BLAKE3 support — a clear error is raised). @@ -343,7 +355,7 @@ Enable optional TTL-based caching to avoid repeated API calls: ```python # Cache GET responses for 5 minutes -client = TeaClient("https://tea.example.com/tea/v1.0.0", cache_ttl=300) +client = TeaClient("https://trust.sbomify.com/tea/v0.4.0", cache_ttl=300) product = client.get_product("uuid") # HTTP call product = client.get_product("uuid") # served from cache @@ -586,15 +598,22 @@ All exceptions inherit from `TeaError`: from libtea.exceptions import TeaError, TeaNotFoundError, TeaRequestError try: - product = client.get_product("unknown-uuid") + product = client.get_product("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") except TeaNotFoundError as exc: print(exc.error_type) # "OBJECT_UNKNOWN" ("OBJECT_NOT_SHAREABLE" on 0.4.0 servers) +except TeaError: + print("Something went wrong") + +try: + client.list_products(page_token="stale-token") except TeaRequestError as exc: - print(exc.error_type) # e.g. "INVALID_PAGE_TOKEN"; None if the body has no TEA error + print(exc.error_type) # "INVALID_PAGE_TOKEN" from a TEA 1.x server; None if the body has no TEA error except TeaError: print("Something went wrong") ``` +The UUID argument must be well formed (a malformed one raises `TeaValidationError` before any request). A TEA 0.x client also rejects a non-numeric `page_token` with `TeaValidationError`, so the second example needs a 1.x server. + `error_type` is the `error` value of the server's TEA error body. `libtea.ErrorType` lists the known values (`OBJECT_UNKNOWN`, `NOT_IMPLEMENTED`, `NO_ACCEPTABLE_FORMAT`, `SIGNATURE_NOT_FOUND`, `INVALID_REQUEST`, `INVALID_PAGE_TOKEN`, and `OBJECT_NOT_SHAREABLE` on 0.4.0); servers may send others, which pass through as plain strings. Compare with `==`. Exception hierarchy: diff --git a/docs/FUTURE.md b/docs/FUTURE.md index 51ba295..66b9e87 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -48,7 +48,7 @@ Items that depend on external factors or are deferred indefinitely. These are ** | `/token` client-credentials exchange | `POST /token` with the `client_credentials` grant (see `auth/readme.md`). Today a caller supplies an already-issued bearer token or basic-auth credentials. | | `ETag` / `If-None-Match` / `304` | Conditional requests and revalidation for the artifact download endpoints. Downloads currently always fetch the full body. | | Signature download endpoints | `/artifact/{uuid}/latest/signature/download` and `/artifact/{uuid}/{artifactVersion}/signature/download`. `signatureUrl` on a format is exposed in the models but not fetched. | -| `sortField` / `sortOrder` | Sorting query parameters on the list and search endpoints. The client's list and search methods do not send them. | +| `sortField` / `sortOrder` | Sorting query parameters on `/products`, `/productReleases`, `/components` and `/componentReleases`, and on the per-parent child lists (a product's releases, a component's releases, and both collections lists). The client sends none of them. | | Routing follow-up requests to the discovery `servers[].rootUrl` | `discover()` returns the servers that host a product release, but later calls keep using the client's own API base. Honouring `rootUrl` + `/v` per release needs a client per server and token scoping (a token must go only to its own API base). | | `purl` → `tei:///purl/` builder | Helper that turns a PURL into the unpadded Base64URL TEI identifier. `parse_tei` already accepts such TEIs and passes the identifier through unchanged; callers must encode it themselves. | | Lowercase-hex checksum conformance check | The spec wants lowercase hexadecimal checksum values. Verification already lowercases both sides; a conformance check should flag servers that send uppercase. | diff --git a/docs/cli.md b/docs/cli.md index 43fcd76..7f094d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -41,7 +41,7 @@ Every command accepts the following options for server selection and authenticat | Option | Description | |--------|-------------| -| `--base-url` *URL* | TEA server base URL (e.g. `https://tea.example.com/tea/v1.0.0`). A `/v` suffix selects the spec version (`v0.4.0` for a TEA 0.4.0 server); without one, TEA 1.0.0 is assumed. Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | +| `--base-url` *URL* | TEA server base URL (e.g. `https://trust.sbomify.com/tea/v0.4.0`, a TEA 0.4.0 server, or `https://tea.example.com/tea/v1.0.0`). A `/v` suffix selects the spec version; without one, TEA 1.0.0 is assumed. Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | | `--domain` *DOMAIN* | Discover server from the domain's `.well-known/tea` endpoint. TEA 1.0.0 is preferred, falling back to 0.4.0. The domain can also be auto-extracted from a TEI argument. | | `--token` *TOKEN* | Bearer token for authentication. Prefer the `TEA_TOKEN` environment variable to avoid exposing the token in shell history. | | `--auth` *USER:PASSWORD* | HTTP basic authentication credentials. Prefer the `TEA_AUTH` environment variable to avoid exposing credentials in shell history. Mutually exclusive with `--token`. | @@ -443,8 +443,8 @@ Using environment variables to avoid repeating credentials: ```bash export TEA_TOKEN="your-bearer-token" -export TEA_BASE_URL="https://tea.example.com/tea/v1.0.0" -tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" +export TEA_BASE_URL="https://trust.sbomify.com/tea/v0.4.0" +tea-cli search-products --id-type PURL --id-value "pkg:github/sbomify/sbomify" tea-cli get-product ``` From 683d90a5aad96b226624047b067075e4437d449e Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:14:19 +0500 Subject: [PATCH 22/29] fix(cli): inspect renders 1.0.0 server-hosted formats and picks newest release Artifact rows tolerate a missing name, type, mediaType or url (TEA 1.0.0 server-hosted formats carry no url). Unpinned components resolve the release with the latest createdDate, since 1.0.0 lists releases oldest first. --- src/libtea/_cli_fmt.py | 16 ++++++------ src/libtea/cli.py | 6 +++-- tests/cli/test_cli.py | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index 00ae086..7ff2139 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -520,20 +520,20 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non if formats: for fmt in formats: tbl.add_row( - escape(art.get("uuid", "-")), - escape(art.get("name", "-")), - escape(art.get("type", "-")), + _esc(art.get("uuid")), + _esc(art.get("name")), + _esc(art.get("type")), escape(applies), - escape(fmt.get("mediaType", "-")), + _esc(fmt.get("mediaType")), _esc(fmt.get("description")), - escape(fmt.get("url", "-")), + _esc(fmt.get("url")), _esc(fmt.get("signatureUrl")), ) else: tbl.add_row( - escape(art.get("uuid", "-")), - escape(art.get("name", "-")), - escape(art.get("type", "-")), + _esc(art.get("uuid")), + _esc(art.get("name")), + _esc(art.get("type")), escape(applies), "-", "-", diff --git a/src/libtea/cli.py b/src/libtea/cli.py index 20334ac..6209fca 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -321,7 +321,8 @@ def _output(data: Any, *, command: str | None = None) -> None: """Output ``data`` as JSON (when ``--json``) or rich-formatted tables/panels. In JSON mode, Pydantic models are serialized via ``model_dump(mode="json", - by_alias=True)`` to produce camelCase keys matching the TEA API wire format. + by_alias=True)``: libtea's model shape with camelCase aliases, stable across + TEA spec versions (not a byte-for-byte copy of any server's wire format). """ output_file, no_color = _get_output_options() if _is_json_output(): @@ -1264,7 +1265,8 @@ def inspect( try: releases = client.get_component_releases(comp_ref.uuid) if releases: - latest = releases[0] + # TEA 1.0.0 lists releases oldest first; pick by createdDate, not position. + latest = max(releases, key=lambda r: r.created_date) cr = client.get_component_release(latest.uuid) comp_data["resolvedRelease"] = cr.model_dump(mode="json", by_alias=True) comp_data["resolvedNote"] = "latest release (not pinned)" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index ad33a19..2106207 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -15,11 +15,15 @@ from libtea.cli import app # noqa: E402 from tests.spec_data import ( # noqa: E402 ARTIFACT, + COMPONENT, + COMPONENT_RELEASE, LEGACY, PRODUCT_RELEASE, V1_ARTIFACT, V1_BASE_URL, V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, ) runner = CliRunner() @@ -833,6 +837,59 @@ def test_inspect_component_ref_release_resolution_error(self): assert "Warning: could not resolve releases for component" in result.stderr +class TestCLIInspectV1: + """``inspect`` against a TEA 1.0.0 server.""" + + TEI = "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + + def _discovery_and_release(self, components: list[dict]) -> None: + responses.get( + f"{V1_BASE_URL}/discovery", + json=[ + {"productReleaseUuid": PRODUCT_RELEASE, "servers": [{"rootUrl": V1_BASE_URL, "versions": ["1.0.0"]}]} + ], + ) + collection = {**V1_COLLECTION, "uuid": PRODUCT_RELEASE, "belongsTo": "PRODUCT_RELEASE", "artifacts": []} + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**V1_PRODUCT_RELEASE, "components": components, "latestCollection": collection}, + ) + + @responses.activate + def test_rich_output_with_unnamed_artifact_and_server_hosted_format(self): + """1.0.0 artifacts may omit ``name`` and formats may omit ``url`` (server-hosted).""" + self._discovery_and_release([{"uuid": COMPONENT, "release": COMPONENT_RELEASE}]) + artifact = {**V1_ARTIFACT, "formats": [{"mediaType": "application/vnd.cyclonedx+json"}]} + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={**V1_RELEASE, "latestCollection": {**V1_COLLECTION, "artifacts": [artifact]}}, + ) + result = runner.invoke(app, ["inspect", self.TEI, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + assert ARTIFACT in _strip_ansi(result.output) + + @responses.activate + def test_unpinned_component_resolves_newest_release(self): + """1.0.0 sorts component releases by ``createdDate`` ascending, so the last one is the newest.""" + newer = "e5f6a7b8-c9d0-1234-efab-567890123456" + self._discovery_and_release([{"uuid": COMPONENT}]) + responses.get(f"{V1_BASE_URL}/component/{COMPONENT}", json={"uuid": COMPONENT, "name": "C", "identifiers": []}) + newer_release = {**V1_RELEASE, "uuid": newer, "version": "2.25.0", "createdDate": "2025-01-10T10:51:00Z"} + responses.get( + f"{V1_BASE_URL}/component/{COMPONENT}/releases", + json={"hasNext": False, "results": [V1_RELEASE, newer_release]}, + ) + for release in (V1_RELEASE, newer_release): + responses.get( + f"{V1_BASE_URL}/componentRelease/{release['uuid']}", + json={**release, "latestCollection": {**V1_COLLECTION, "uuid": release["uuid"]}}, + ) + result = runner.invoke(app, ["--json", "inspect", self.TEI, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + resolved = json.loads(result.stdout)[0]["components"][0]["resolvedRelease"] + assert resolved["release"]["version"] == "2.25.0" + + class TestCLITeiAutoDiscovery: """Test TEI auto-discovery: when neither --base-url nor --domain is given.""" From 3e7f3f462728df1dac5e146b877dc705c7dd9dc1 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:15:21 +0500 Subject: [PATCH 23/29] fix(discovery): prefer exact supported versions over a compatible downgrade With version=None, from_well_known now builds its attempts from exact matches across every supported version (keeping cross-version failover) and only falls back to a compatible downgrade when none is advertised as-is. A server offering 0.4.0 and 1.0.0-rc.1 now negotiates 0.4.0 without a warning. --- src/libtea/client.py | 46 +++++++++++++++++++++------------- tests/client/test_discovery.py | 24 ++++++++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/libtea/client.py b/src/libtea/client.py index 9f33e13..e034bc8 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -178,7 +178,8 @@ def from_well_known( timeout: Request timeout in seconds (default 30). version: TEA spec SemVer to match against. The default (``None``) negotiates the highest of :data:`SUPPORTED_SPEC_VERSIONS` the server advertises: - ``1.0.0`` first, then ``0.4.0``. + ``1.0.0`` first, then ``0.4.0``. Only when neither is advertised exactly does it + downgrade to a compatible version (e.g. ``0.3.0``), with a warning. scheme: URL scheme for discovery — ``"https"`` (default) or ``"http"``. port: Optional port for ``.well-known`` resolution. max_retries: Retry count on 5xx (default 3). @@ -201,22 +202,33 @@ def from_well_known( # Without an explicit ``version`` every supported version contributes, so failover can cross versions. wanted_versions = (version,) if version is not None else SUPPORTED_SPEC_VERSIONS attempts: dict[tuple[str, str], None] = {} # insertion-ordered set - for wanted in wanted_versions: - try: - best = select_best_endpoint(well_known, wanted) - except TeaDiscoveryError: - if version is not None: - raise - continue - matched_version = best.matched_version - try: - # Rejects an unsupported version (possible with an explicit ``version``) before any probe. - _resolve_spec_version(best.endpoint.url, matched_version) - except ValueError as exc: - raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc - for endpoint in select_endpoints(well_known, matched_version): - # Build metadata does not affect precedence, so each endpoint may spell the version differently. - attempts[(endpoint.url, _advertised_version(endpoint, matched_version))] = None + if version is None: + # Exact matches first: a prerelease such as ``1.0.0-rc.1`` is not a version this client supports, + # so a compatible downgrade is only a fallback when no supported version is advertised as-is. + for wanted in SUPPORTED_SPEC_VERSIONS: + try: + exact = select_endpoints(well_known, wanted) + except TeaDiscoveryError: + continue + for endpoint in exact: + attempts[(endpoint.url, _advertised_version(endpoint, wanted))] = None + if not attempts: + for wanted in wanted_versions: + try: + best = select_best_endpoint(well_known, wanted) + except TeaDiscoveryError: + if version is not None: + raise + continue + matched_version = best.matched_version + try: + # Rejects an unsupported version (possible with an explicit ``version``) before any probe. + _resolve_spec_version(best.endpoint.url, matched_version) + except ValueError as exc: + raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc + for endpoint in select_endpoints(well_known, matched_version): + # Build metadata does not affect precedence, so each endpoint may spell the version differently. + attempts[(endpoint.url, _advertised_version(endpoint, matched_version))] = None if not attempts: offered = sorted({v for ep in well_known.endpoints for v in ep.versions}) raise TeaDiscoveryError( diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index 4097271..b3c25b5 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -881,6 +881,30 @@ def test_fails_over_across_supported_versions(self): "https://b.example.com/v0.4.0", ] + @responses.activate + def test_exact_supported_version_beats_prerelease(self): + """A 1.0.0 prerelease is not 1.0.0; the exact 0.4.0 match wins and nothing warns.""" + import warnings + + responses.head(f"{self.API}/v1.0.0-rc.1", status=200) + responses.head(f"{self.API}/v0.4.0", status=200) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = self._discover({"url": self.API, "versions": ["0.4.0", "1.0.0-rc.1"]}) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [f"{self.API}/v0.4.0"] + assert [str(w.message) for w in caught] == [] + + @responses.activate + def test_downgrades_when_no_supported_version_matches_exactly(self): + responses.head(f"{self.API}/v0.3.0", status=200) + with pytest.warns(UserWarning, match="negotiated to server version 0.3.0"): + client = self._discover({"url": self.API, "versions": ["0.3.0"]}) + assert client.spec_version == "0.3.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [f"{self.API}/v0.3.0"] + @responses.activate def test_higher_version_beats_higher_priority(self): responses.head("https://new.example.com/v1.0.0", status=200) From 058e2d1d92db0a350b89c0d26bd8735af37d536d Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:18:39 +0500 Subject: [PATCH 24/29] fix: harden response handling for TEA 1.0.0 servers - Deeply nested JSON on 2xx API and .well-known responses raises TeaValidationError / TeaDiscoveryError instead of RecursionError. - Iterators follow nextPageToken past an empty page that still has hasNext; the seen-token set and page cap keep the loop bounded. - A non-string page_token raises TeaValidationError before any request. - TEA 1.0.0 pages must carry results. - Conformance discovery_404 on 1.0.0 requires a TEA error body. --- docs/conformance.md | 2 +- src/libtea/_http.py | 2 +- src/libtea/client.py | 6 ++++- src/libtea/conformance/_checks.py | 6 +++-- src/libtea/discovery.py | 2 +- src/libtea/models.py | 2 ++ tests/client/test_client_v1.py | 33 ++++++++++++++++++++++++--- tests/client/test_discovery.py | 6 +++++ tests/client/test_http.py | 7 ++++++ tests/unit/test_conformance_checks.py | 13 +++++++++++ tests/unit/test_models.py | 9 ++++++++ 11 files changed, 79 insertions(+), 9 deletions(-) diff --git a/docs/conformance.md b/docs/conformance.md index a36c8a9..2705ec8 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -141,7 +141,7 @@ Each check returns one of four statuses: | Check | Description | |-------|-------------| | `discovery` | Discover a TEI and verify the server returns results | -| `discovery_404` | Verify unknown TEI returns 404 (TEA 0.x also accepts an empty list; on TEA 1.x a successful discovery response must be a non-empty array) | +| `discovery_404` | Verify unknown TEI returns 404 (TEA 0.x also accepts an empty list; on TEA 1.x the 404 must carry a TEA error body and a successful discovery response must be a non-empty array) | ### Products (5 checks) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index 38cab9e..0ef452b 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -246,7 +246,7 @@ def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any: raise TeaValidationError(f"Response body exceeds limit: >{limit} bytes (limit {limit})") try: result = json.loads(body) - except (ValueError, UnicodeDecodeError) as exc: + except (ValueError, UnicodeDecodeError, RecursionError) as exc: # RecursionError: deep nesting raise TeaValidationError(f"Invalid JSON in response: {exc}") from exc # Cache store (bounded: evict least-recently-stored entry when full) diff --git a/src/libtea/client.py b/src/libtea/client.py index e034bc8..865acc4 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -296,6 +296,10 @@ def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[D def _page_params(self, page_token: str | None, page_size: int) -> dict[str, typing.Any]: """Build the paging query: an opaque ``pageToken`` on TEA 1.x, an integer ``pageOffset`` on 0.x.""" + if page_token is not None and not isinstance(page_token, str): + raise TeaValidationError( + f"page_token must be a string (a previous page's nextPageToken), got {type(page_token).__name__}" + ) if not self.is_legacy: _validate_page_size(page_size, _MAX_PAGE_SIZE_V1) return {"pageSize": page_size} if page_token is None else {"pageSize": page_size, "pageToken": page_token} @@ -872,7 +876,7 @@ def _paginate(fetch_page: typing.Callable[..., typing.Any], page_size: int, **kw for _ in range(_MAX_PAGES): page = fetch_page(**kwargs, page_token=token, page_size=page_size) yield from page.results - if not page.has_next or not page.results: + if not page.has_next: # an empty page may still carry a token; seen-set and cap bound the loop return token = page.next_page_token if token in seen: diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index ffb7e47..70f781b 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -102,7 +102,7 @@ def check_discovery(client: TeaClient, ctx: CheckContext) -> CheckResult: def check_discovery_404(client: TeaClient, ctx: CheckContext) -> CheckResult: - """Discover a fake TEI and expect TeaNotFoundError (or, on TEA 0.x, an empty list).""" + """Discover a fake TEI and expect a 404: with a TEA error body on TEA 1.x; on TEA 0.x an empty list also passes.""" name = "discovery_404" if client.is_legacy: fake_tei = f"urn:tei:uuid:nonexistent.example.com:{_ZERO_UUID}" @@ -110,7 +110,9 @@ def check_discovery_404(client: TeaClient, ctx: CheckContext) -> CheckResult: fake_tei = f"tei://nonexistent.example.com/uuid/{_ZERO_UUID}" try: results = client.discover(fake_tei) - except TeaNotFoundError: + except TeaNotFoundError as exc: + if not client.is_legacy and exc.error_type is None: + return _fail(name, "Server returned 404 without a TEA error body; TEA 1.0.0 requires a TEA error body") return _pass(name, "Server returned 404 for unknown TEI") except TeaError as exc: if _is_empty_discovery(exc): diff --git a/src/libtea/discovery.py b/src/libtea/discovery.py index 663c820..7da5808 100644 --- a/src/libtea/discovery.py +++ b/src/libtea/discovery.py @@ -217,7 +217,7 @@ def fetch_well_known( try: data = response.json() - except ValueError as exc: + except (ValueError, RecursionError) as exc: # RecursionError: deep nesting raise TeaDiscoveryError(f"Invalid JSON in .well-known/tea response from {domain}") from exc finally: if response is not None: diff --git a/src/libtea/models.py b/src/libtea/models.py index 758fbcf..19772ca 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -631,6 +631,8 @@ class _PaginatedResponse(_TeaModel, Generic[_R]): def _v1_problems(self) -> list[str]: problems = super()._v1_problems() + if "results" not in self.model_fields_set: + problems.append("missing required field 'results'") if self.has_next and not self.next_page_token: problems.append("hasNext is true but nextPageToken is missing") return problems diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 9f4913b..613f747 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -241,10 +241,27 @@ def test_iter_stops_at_page_cap(v1, monkeypatch): @responses.activate -def test_iter_stops_on_empty_page(v1): +def test_iter_follows_token_past_empty_page(v1): + """An empty page with ``hasNext`` and a fresh token is not the end of the results.""" + url = f"{V1_BASE_URL}/products" + pages = [ + (None, {"hasNext": True, "nextPageToken": "t1", "results": [product(PRODUCT, "P1")]}), + ("t1", {"hasNext": True, "nextPageToken": "t2", "results": []}), + ("t2", {"hasNext": False, "results": [product(PRODUCT, "P2"), product(PRODUCT, "P3")]}), + ] + for sent, page in pages: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get(url, match=[matchers.query_param_matcher(query)], json=page) + assert [p.name for p in v1.iter_products("PURL", "pkg:x")] == ["P1", "P2", "P3"] + assert len(responses.calls) == 3 + + +@responses.activate +def test_iter_raises_on_repeated_token_with_empty_pages(v1): responses.get(f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": []}) - assert list(v1.iter_products("PURL", "pkg:x")) == [] - assert len(responses.calls) == 1 + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 2 @responses.activate @@ -311,6 +328,16 @@ def test_legacy_non_numeric_token_rejected(legacy, token): # Review Focus 2 assert len(responses.calls) == 0 +@responses.activate +@pytest.mark.parametrize("fixture", ["v1", "legacy"]) +def test_non_string_page_token_rejected(request, fixture): + """An int carried over from the old ``page_offset`` argument fails cleanly before any request.""" + client = request.getfixturevalue(fixture) + with pytest.raises(TeaValidationError, match="page_token must be a string"): + client.list_products(page_token=200) + assert len(responses.calls) == 0 + + def test_legacy_page_size_allows_10000(legacy): with pytest.raises(TeaValidationError, match="between 1 and 10000"): legacy.list_products(page_size=10001) diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index b3c25b5..049da76 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -290,6 +290,12 @@ def test_fetch_well_known_non_json_raises_discovery_error(self): with pytest.raises(TeaDiscoveryError, match="Invalid JSON"): fetch_well_known("example.com") + @responses.activate + def test_fetch_well_known_deeply_nested_json_raises_discovery_error(self): + responses.get("https://example.com/.well-known/tea", body=b"[" * 100_000) + with pytest.raises(TeaDiscoveryError, match="Invalid JSON"): + fetch_well_known("example.com") + @responses.activate def test_fetch_well_known_invalid_schema_raises_discovery_error(self): responses.get("https://example.com/.well-known/tea", json={"bad": "data"}) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index b41cc42..d103025 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -129,6 +129,13 @@ def test_get_json_non_json_response_raises_validation_error(self, http_client, b with pytest.raises(TeaValidationError, match="Invalid JSON"): http_client.get_json("/product/abc") + @responses.activate + def test_get_json_deeply_nested_body_raises_validation_error(self, http_client, base_url): + """json.loads raises RecursionError, not ValueError, when nesting exceeds the recursion limit.""" + responses.get(f"{base_url}/product/abc", body=b"[" * 100_000, status=200) + with pytest.raises(TeaValidationError, match="Invalid JSON"): + http_client.get_json("/product/abc") + @responses.activate def test_download_creates_parent_directories(self, http_client, tmp_path): content = b"nested file" diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 63e07bd..3b7f008 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -294,6 +294,19 @@ def test_v1_uses_tei_uri(self, v1): assert len(responses.calls) == 1 assert "tei%3A%2F%2Fnonexistent.example.com%2Fuuid%2F" + _ZERO_UUID in responses.calls[0].request.url + @responses.activate + def test_v1_fail_on_404_without_tea_error_body(self, v1): + """A bare 404 (e.g. an unmounted path) is not a TEA "not found" answer on 1.0.0.""" + responses.get(f"{V1_BASE_URL}/discovery", status=404, body="nginx") + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "TEA 1.0.0 requires a TEA error body" in result.message + + @responses.activate + def test_legacy_passes_on_404_without_tea_error_body(self, legacy): + responses.get(f"{BASE_URL}/discovery", status=404, body="nginx") + assert check_discovery_404(legacy, CheckContext()).status == CheckStatus.PASS + @responses.activate def test_legacy_keeps_urn(self, legacy): responses.get(f"{BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 3adc98b..c2f8495 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -418,6 +418,15 @@ def test_non_object_rejected(self): with pytest.raises(ValidationError): PaginatedProductResponse.model_validate([]) + def test_v1_missing_results_rejected(self): + with pytest.raises(ValidationError, match="missing required field 'results'"): + PaginatedProductResponse.model_validate({"hasNext": False}) + + def test_legacy_missing_results_still_accepted(self): + data = {"timestamp": "2024-03-20T15:30:00Z", "pageStartIndex": 0, "pageSize": 2, "totalResults": 0} + p = PaginatedProductResponse.model_validate(data, context=LEGACY) + assert (p.results, p.has_next) == ((), False) + def test_v1_has_next_without_token_rejected(self): with pytest.raises(ValidationError, match="nextPageToken"): PaginatedProductResponse.model_validate({"hasNext": True, "results": []}) From da9c7633f52603e41111e300d422985ca5b1abd5 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:20:25 +0500 Subject: [PATCH 25/29] fix(download): reject HTTPS to HTTP redirect downgrades Artifact downloads now refuse a redirect from an https hop to http, as discovery already does. Regression guards: basic auth is sent on the first hop and dropped on the redirect hop, and the same-host redirect test checks the first hop carried the bearer token. --- src/libtea/_http.py | 10 +++++++--- tests/client/test_client_v1.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/libtea/_http.py b/src/libtea/_http.py index 0ef452b..abb3a03 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -298,7 +298,8 @@ def download_with_hashes( ValueError: If ``authenticated`` and ``url`` is not under the API base. TeaConnectionError: On network failure. Partial files are deleted. TeaChecksumError: If an unsupported algorithm is requested. - TeaValidationError: If download exceeds max_download_bytes or fails SSRF check. + TeaValidationError: If download exceeds max_download_bytes, fails SSRF check, or a redirect + downgrades HTTPS to HTTP. """ if authenticated: if not url.startswith(f"{self._base_url}/"): @@ -330,8 +331,11 @@ def download_with_hashes( location = response.headers.get("Location") if not location: raise TeaRequestError(f"Redirect without Location header: HTTP {response.status_code}") - current_url = urljoin(current_url, location) - _validate_download_url(current_url, allow_private_ips=allow_private_ips) + next_url = urljoin(current_url, location) + _validate_download_url(next_url, allow_private_ips=allow_private_ips) + if urlparse(current_url).scheme == "https" and urlparse(next_url).scheme == "http": + raise TeaValidationError(f"Download redirect downgraded HTTPS to HTTP: {next_url}") + current_url = next_url session, params = download_session, None response.close() response = None diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 613f747..8b0f749 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -426,10 +426,40 @@ def test_token_not_forwarded_on_redirect_to_api_host(tmp_path): ) responses.get("https://api.example.com/blobs/abc", body=b"{}") c.download_artifact_content(ARTIFACT, tmp_path / "a.json", media_type=MEDIA, version=2) + assert responses.calls[0].request.headers["Authorization"] == "Bearer secret" assert responses.calls[1].request.url == "https://api.example.com/blobs/abc" assert "Authorization" not in responses.calls[1].request.headers +@responses.activate +def test_basic_auth_not_forwarded_on_redirect(tmp_path): + c = TeaClient(V1_BASE_URL, basic_auth=("u", "p")) + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "https://cdn.example.com/blob/abc"}, + ) + responses.get("https://cdn.example.com/blob/abc", body=b"{}") + c.download_artifact_content(ARTIFACT, tmp_path / "a.json", media_type=MEDIA, version=2) + assert responses.calls[0].request.headers["Authorization"] == "Basic dTpw" # base64("u:p") + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_download_rejects_https_to_http_redirect(tmp_path): + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "http://cdn.example.com/blob/abc"}, + ) + dest = tmp_path / "a.json" + with pytest.raises(TeaValidationError, match="HTTPS to HTTP"): + c.download_artifact_content(ARTIFACT, dest, media_type=MEDIA, version=2) + assert len(responses.calls) == 1 + assert not dest.exists() + + @responses.activate def test_latest_when_version_omitted(v1, tmp_path): responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", body=b"{}") From 8c7b7bc9cd24770a8d42caafd71fa02482dcd856 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:22:05 +0500 Subject: [PATCH 26/29] docs: upgrade notes for Python-built models and version checks, more deferred items README Upgrading: Python-built models follow the 1.0.0 rules (use the legacy context for 0.4.0-shaped data), unsupported /v majors raise ValueError, dict keyword construction of ComponentReleaseWithCollection fails, and negotiation downgrades only without an exact match. FUTURE.md: a --spec-version override for the CLI and conformance tools, and bounding 3xx bodies that requests reads during downloads. --- README.md | 6 ++++-- docs/FUTURE.md | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c56c17e..04bdd9b 100644 --- a/README.md +++ b/README.md @@ -639,7 +639,8 @@ libtea now targets TEA 1.0.0 and still talks to 0.4.0 servers. These Python API **Spec versions** - A base URL without a `/v` suffix (and no `spec_version=`) is now TEA 1.0.0, not 0.4.0. `TEA_SPEC_VERSION` is `"1.0.0"`. To keep talking to a 0.4.0 server, pass `spec_version="0.4.0"` or end the base URL in `/v0.4.0`. -- `TeaClient.from_well_known(version=None)` now negotiates: it prefers 1.0.0, falls back to 0.4.0, and fails over across both. It used to default to `"0.4.0"`. Pass `version="0.4.0"` to pin the old behaviour. The API path uses the version spelling exactly as the server advertises it (for example `/v1.0.0+build.5`). +- `TeaClient.from_well_known(version=None)` now negotiates: it prefers 1.0.0, falls back to 0.4.0, and fails over across both. It used to default to `"0.4.0"`. Pass `version="0.4.0"` to pin the old behaviour. It downgrades to another compatible version (with a warning) only when neither is advertised exactly; a prerelease such as `1.0.0-rc.1` does not count as 1.0.0. The API path uses the version spelling exactly as the server advertises it (for example `/v1.0.0+build.5`). +- A base URL ending in `/v` with a major version other than 0 or 1 (for example `/v2.0.0`) now raises `ValueError`. - `TeaClient` gains `spec_version` and `is_legacy`. `TeaClientProtocol` gains them too, plus `download_artifact_content` and the new `discover` signature, so test doubles that implement the protocol need updating. **Discovery and TEIs** @@ -658,9 +659,10 @@ libtea now targets TEA 1.0.0 and still talks to 0.4.0 servers. These Python API - `Collection.date` is now `Collection.created_date`. A 0.4.0 `date` is mapped to it. - `get_product_release` returns `ProductReleaseWithCollection`, a `ProductRelease` subclass with `latest_collection` (`None` on 0.4.0 servers). Because of that, JSON output for a product release (`tea-cli --json get-release`, `tea-cli inspect`) now includes `latestCollection`: the collection on 1.x servers, `null` on 0.4.0 servers. -- `ComponentReleaseWithCollection` still exposes `.release` and `.latest_collection`, and now accepts the flattened 1.0.0 wire shape. +- `ComponentReleaseWithCollection` still exposes `.release` and `.latest_collection`, and now accepts the flattened 1.0.0 wire shape. Keyword construction with a dict, `ComponentReleaseWithCollection(release={...}, ...)`, now fails; pass a `Release` instance instead. - Fields that both spec versions require are no longer optional: `Checksum.algorithm_type` and `algorithm_value`, `Artifact.uuid`, `type` and `formats`, and `ReleaseDistribution.distribution_id`. Building one without them now fails. - Fields that only 1.0.0 requires (for example `Release.component`, `ProductRelease.product`, `Artifact.version` and `created_date`, `ArtifactFormat.media_type`, `Identifier.id_type` and `id_value`, and the `Collection` fields) keep their optional types, but a 1.x response that omits one raises `TeaValidationError`. 0.4.0 responses may omit them. +- Models you build in Python follow the 1.0.0 rules too: leaving out a field that only 1.0.0 requires raises pydantic `ValidationError`. That breaks test doubles such as `Collection()`, `Identifier(id_type="PURL")` and a `Paginated*Response` built without `has_next` or `results`. For 0.4.0-shaped data, pass the wire (camelCase) dict to `model_validate(..., context={"tea_legacy": True})`. Don't nest instances built that way into a model you build without that context; the outer model checks them against the 1.0.0 rules and fails. - Removed: `ReleaseDistribution.distribution_type`, `Artifact.distribution_types`, and the `mimeType` alias for `ArtifactFormat.media_type`. - `ComplianceDocumentType.FEDRAMP` is now `"FEDRAMP"` (was `"FedRAMP"`), and `EU_DECLARATION_OF_CONFORMITY` is new. diff --git a/docs/FUTURE.md b/docs/FUTURE.md index 66b9e87..326672e 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -51,12 +51,25 @@ Items that depend on external factors or are deferred indefinitely. These are ** | `sortField` / `sortOrder` | Sorting query parameters on `/products`, `/productReleases`, `/components` and `/componentReleases`, and on the per-parent child lists (a product's releases, a component's releases, and both collections lists). The client sends none of them. | | Routing follow-up requests to the discovery `servers[].rootUrl` | `discover()` returns the servers that host a product release, but later calls keep using the client's own API base. Honouring `rootUrl` + `/v` per release needs a client per server and token scoping (a token must go only to its own API base). | | `purl` → `tei:///purl/` builder | Helper that turns a PURL into the unpadded Base64URL TEI identifier. `parse_tei` already accepts such TEIs and passes the identifier through unchanged; callers must encode it themselves. | +| `--spec-version` override for the CLI, `run_conformance` and the pytest plugin | A base URL without a `/v` suffix now means TEA 1.0.0. `TeaClient(spec_version=...)` can force 0.4.0, but `tea-cli`, `run_conformance` and `--tea-base-url` cannot, so they treat a 0.4.0 server behind an unversioned base URL as 1.0.0. | | Lowercase-hex checksum conformance check | The spec wants lowercase hexadecimal checksum values. Verification already lowercases both sides; a conformance check should flag servers that send uppercase. | **Action:** Schedule individual items when a consumer needs them; each is independent. --- +## HTTP Layer Follow-ups + +**Blocked on:** Nothing — a known gap in how `requests` handles redirects. + +| Item | Notes | +|------|-------| +| Bound 3xx bodies during downloads | Even with `allow_redirects=False` and `stream=True`, `requests.Session.send` reads the whole 3xx response body (to release the connection) before libtea sees it, so `max_download_bytes` does not cap a redirect body. Fixing it needs a transport adapter (or a urllib3-level request) that skips that read. | + +**Action:** Address if a server is found sending large redirect bodies. + +--- + ## Conformance Follow-ups **Blocked on:** Nothing — low-priority items from earlier alignment reviews. From 2cb36cbe210d7ad2b8deeaaca2bf0c670ecefa72 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Wed, 21 Oct 2026 21:39:38 +0500 Subject: [PATCH 27/29] docs: note HTTPS to HTTP download redirect rejection in upgrade notes --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 04bdd9b..8d8c9a8 100644 --- a/README.md +++ b/README.md @@ -679,6 +679,10 @@ md5 = Checksum.model_validate( ) ``` +**Downloads** + +- `download_artifact` and `download_artifact_content` now refuse a redirect from HTTPS to HTTP and raise `TeaValidationError`; the partial file is deleted. HTTP-to-HTTP and HTTP-to-HTTPS redirects still work. + **Errors** - `ErrorResponse.error` is a `str` (was `ErrorType`); compare it with `==`. `ErrorResponse.message` is new, and `ErrorType` has the 1.0.0 values. From 86aa71184dff41460b1127247e2cb15332a4be7a Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 22 Oct 2026 16:29:06 +0500 Subject: [PATCH 28/29] fix(conformance): search checks follow page tokens past empty TEA 1.x pages --- src/libtea/conformance/_checks.py | 14 ++++---- tests/unit/test_conformance_checks.py | 51 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index 70f781b..16b7471 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -159,12 +159,13 @@ def check_search_products(client: TeaClient, ctx: CheckContext) -> CheckResult: if not ident.id_type or not ident.id_value: return _skip(name, "First identifier missing type or value") try: - resp = client.search_products(ident.id_type, ident.id_value) + # The iterator follows page tokens: a TEA 1.x page may be empty and still have more after it. + found = next(client.iter_products(ident.id_type, ident.id_value), None) except TeaError as exc: return _fail(name, f"search_products() failed: {exc}", details=str(exc)) - if not resp.results: + if found is None: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {len(resp.results)} product(s)") + return _pass(name, f"Search found product {found.uuid} for a known identifier") def check_get_product(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -244,12 +245,13 @@ def check_search_product_releases(client: TeaClient, ctx: CheckContext) -> Check if not ident.id_type or not ident.id_value: return _skip(name, "First identifier missing type or value") try: - resp = client.search_product_releases(ident.id_type, ident.id_value) + # The iterator follows page tokens: a TEA 1.x page may be empty and still have more after it. + found = next(client.iter_product_releases(ident.id_type, ident.id_value), None) except TeaError as exc: return _fail(name, f"search_product_releases() failed: {exc}", details=str(exc)) - if not resp.results: + if found is None: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {len(resp.results)} release(s)") + return _pass(name, f"Search found release {found.uuid} for a known identifier") def check_get_product_release(client: TeaClient, ctx: CheckContext) -> CheckResult: diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 3b7f008..ed11f71 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -36,7 +36,15 @@ check_uuid_format, ) from libtea.conformance._types import CheckStatus -from tests.spec_data import COMPONENT, PRODUCT, V1_BASE_URL, product +from tests.spec_data import ( + COMPONENT, + PRODUCT, + PRODUCT_RELEASE, + V1_BASE_URL, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + product, +) BASE_URL = "https://api.example.com/tea/v0.4.0" @@ -550,6 +558,25 @@ def test_fail_no_results(self): assert result.status == CheckStatus.FAIL assert "no results" in result.message + @responses.activate + def test_v1_follows_token_past_empty_first_page(self, v1): + known = {**product(PRODUCT, "A"), "identifiers": [{"idType": "PURL", "idValue": "pkg:x"}]} + responses.get(f"{V1_BASE_URL}/product/{PRODUCT}", json=known) + search = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100"} + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher(search)], + json={"hasNext": True, "nextPageToken": "t2", "results": []}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({**search, "pageToken": "t2"})], + json={"hasNext": False, "results": [known]}, + ) + result = check_search_products(v1, CheckContext(product_uuid=PRODUCT)) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 3 + class TestCheckProductReleases: @responses.activate @@ -694,6 +721,28 @@ def test_fail_no_results(self): assert result.status == CheckStatus.FAIL assert "no results" in result.message + @responses.activate + def test_v1_follows_token_past_empty_first_page(self, v1): + release = {**V1_PRODUCT_RELEASE, "identifiers": [{"idType": "PURL", "idValue": "pkg:x@1"}]} + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**release, "latestCollection": {**V1_COLLECTION, "belongsTo": "PRODUCT_RELEASE"}}, + ) + search = {"idType": "PURL", "idValue": "pkg:x@1", "pageSize": "100"} + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher(search)], + json={"hasNext": True, "nextPageToken": "t2", "results": []}, + ) + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher({**search, "pageToken": "t2"})], + json={"hasNext": False, "results": [release]}, + ) + result = check_search_product_releases(v1, CheckContext(product_release_uuid=PRODUCT_RELEASE)) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 3 + class TestCheckGetProductRelease: @responses.activate From 0eff4bca274291d7101e416a4a4eb2b85dc1cff9 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Sun, 25 Oct 2026 17:11:51 +0500 Subject: [PATCH 29/29] fix: use the TEA 0.4.0 artifact path on legacy servers TEA 0.4.0 serves artifact metadata at /artifact/{uuid}; /artifact/{uuid}/latest and /artifact/{uuid}/{version} exist only in 1.x. A live conformance run against trust.sbomify.com (0.4.0) failed get_artifact with 404. get_artifact now picks the path from the negotiated version, and get_artifact_version raises TeaValidationError on 0.4.0 servers before any request. --- CLAUDE.md | 2 +- README.md | 4 +++- docs/cli.md | 2 +- src/libtea/client.py | 10 +++++++++- tests/cli/test_cli.py | 25 ++++++++++++++++++++----- tests/client/test_client.py | 21 ++++++--------------- tests/client/test_client_v1.py | 14 ++++++++++++++ tests/client/test_iterators.py | 2 +- tests/unit/test_conformance_checks.py | 12 ++++++------ 9 files changed, 61 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9a8b0d..b729d96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ conformance/ Conformance test suite (optional dependency: pytest) - `ChecksumAlgorithm` values may arrive as `SHA_256` (underscore) or `SHA-256` (hyphen) from servers — the `@field_validator` in `Checksum` normalizes both - BLAKE3 is in the enum for spec completeness but NOT supported at runtime (not in stdlib `hashlib`) — raises `TeaChecksumError` - `Identifier.id_type` is typed as `str` (not `IdentifierType` enum) so unknown types from future spec versions pass through -- **Behaviour follows the negotiated version, never response-shape sniffing.** `TeaClient` resolves `spec_version` once (explicit argument, then the base URL's `/v` suffix, then `TEA_SPEC_VERSION` = 1.0.0) and exposes `is_legacy`. Pagination parameters, page-size limits, discovery by PURL and server-hosted downloads all branch on it. Never infer the version from what a response looks like +- **Behaviour follows the negotiated version, never response-shape sniffing.** `TeaClient` resolves `spec_version` once (explicit argument, then the base URL's `/v` suffix, then `TEA_SPEC_VERSION` = 1.0.0) and exposes `is_legacy`. Pagination parameters, page-size limits, the artifact metadata path (`/artifact/{uuid}` on 0.4.0, `/latest` on 1.x), artifact versions, discovery by PURL and server-hosted downloads all branch on it. Never infer the version from what a response looks like - **0.4.0 shims run only under the validation context `{"tea_legacy": True}`.** `_validation._validate(..., legacy=client.is_legacy)` supplies it; no context means 1.0.0 rules. Fields required by both versions are non-Optional model types; fields only 1.0.0 requires stay `X | None` and are rejected when missing outside the legacy context (`_v1_required` / `_v1_problems()` on `_TeaModel`). Building an MD5 `Checksum` by hand therefore needs `Checksum.model_validate({...}, context={"tea_legacy": True})` - CGNAT range (100.64.0.0/10, RFC 6598) is checked separately in SSRF protection because `ipaddress.is_private` misses it on Python 3.11+ diff --git a/README.md b/README.md index bf85bfe..2b317d9 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ On a 0.4.0 server: - `page_size` may be up to 10000 (up to 100 on 1.x); - MD5 checksums are accepted; - fields that only 1.0.0 requires may be missing; -- discovery by PURL and `download_artifact_content` are unavailable and raise `TeaValidationError`. +- `get_artifact` reads `/artifact/{uuid}` (1.x uses `/artifact/{uuid}/latest`); +- discovery by PURL, `get_artifact_version` and `download_artifact_content` are unavailable and raise `TeaValidationError`. ### Discovery @@ -638,6 +639,7 @@ libtea now targets TEA 1.0.0 and still talks to 0.4.0 servers. These Python API - A base URL without a `/v` suffix (and no `spec_version=`) is now TEA 1.0.0, not 0.4.0. `TEA_SPEC_VERSION` is `"1.0.0"`. To keep talking to a 0.4.0 server, pass `spec_version="0.4.0"` or end the base URL in `/v0.4.0`. - `TeaClient.from_well_known(version=None)` now negotiates: it prefers 1.0.0, falls back to 0.4.0, and fails over across both. It used to default to `"0.4.0"`. Pass `version="0.4.0"` to pin the old behaviour. It downgrades to another compatible version (with a warning) only when neither is advertised exactly; a prerelease such as `1.0.0-rc.1` does not count as 1.0.0. The API path uses the version spelling exactly as the server advertises it (for example `/v1.0.0+build.5`). - A base URL ending in `/v` with a major version other than 0 or 1 (for example `/v2.0.0`) now raises `ValueError`. +- On 0.4.0 servers `get_artifact` now calls the 0.4.0 path `/artifact/{uuid}` (libtea 0.5.1 called `/artifact/{uuid}/latest`, which 0.4.0 servers answer with 404), and `get_artifact_version` raises `TeaValidationError` before any request, since 0.4.0 has no versioned artifact endpoint. - `TeaClient` gains `spec_version` and `is_legacy`. `TeaClientProtocol` gains them too, plus `download_artifact_content` and the new `discover` signature, so test doubles that implement the protocol need updating. **Discovery and TEIs** diff --git a/docs/cli.md b/docs/cli.md index 7f094d4..0ea3883 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -257,7 +257,7 @@ tea-cli get-artifact UUID ### get-artifact-version -Get a specific version of artifact metadata by UUID. +Get a specific version of artifact metadata by UUID. Needs a TEA 1.x server: TEA 0.4.0 has no versioned artifact endpoint. ``` tea-cli get-artifact-version UUID VERSION diff --git a/src/libtea/client.py b/src/libtea/client.py index 865acc4..1b34811 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -695,13 +695,16 @@ def get_component_release_cle(self, uuid: str) -> CLE: def get_artifact(self, uuid: str) -> Artifact: """Get the latest revision of artifact metadata by UUID. + TEA 1.x serves it at ``/artifact/{uuid}/latest``; TEA 0.4.0 at ``/artifact/{uuid}``. + Args: uuid: Artifact UUID. Returns: The artifact with its formats and download URLs. """ - data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/latest") + path = f"/artifact/{_validate_path_segment(uuid)}" + data = self._http.get_json(path if self.is_legacy else f"{path}/latest") return _validate(Artifact, data, legacy=self.is_legacy) def get_artifact_version(self, uuid: str, version: int) -> Artifact: @@ -713,8 +716,13 @@ def get_artifact_version(self, uuid: str, version: int) -> Artifact: Returns: The artifact with its formats and download URLs. + + Raises: + TeaValidationError: On a TEA 0.4.0 server, which has no versioned artifact endpoint. """ _validate_artifact_version(version) + if self.is_legacy: + raise TeaValidationError("Artifact versions require a TEA 1.x server") data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/{version}") return _validate(Artifact, data, legacy=self.is_legacy) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 2106207..458d98c 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -121,7 +121,7 @@ def test_discover(self): def test_get_artifact(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" responses.get( - f"{BASE_URL}/artifact/{uuid}/latest", + f"{BASE_URL}/artifact/{uuid}", json={"uuid": uuid, "name": "SBOM", "type": "BOM", "formats": []}, ) result = runner.invoke(app, ["--json", "get-artifact", uuid, "--base-url", BASE_URL]) @@ -236,14 +236,29 @@ def test_search_component_releases(self): def test_get_artifact_version(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" responses.get( - f"{BASE_URL}/artifact/{uuid}/2", - json={"uuid": uuid, "name": "SBOM", "type": "BOM", "version": 2, "formats": []}, + f"{V1_BASE_URL}/artifact/{uuid}/2", + json={ + "uuid": uuid, + "name": "SBOM", + "type": "BOM", + "version": 2, + "createdDate": "2024-12-13T00:00:00Z", + "formats": [{"mediaType": "application/json"}], + }, ) - result = runner.invoke(app, ["--json", "get-artifact-version", uuid, "2", "--base-url", BASE_URL]) + result = runner.invoke(app, ["--json", "get-artifact-version", uuid, "2", "--base-url", V1_BASE_URL]) assert result.exit_code == 0 data = json.loads(result.output) assert data["name"] == "SBOM" + @responses.activate + def test_get_artifact_version_needs_tea_1x(self): + uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + result = runner.invoke(app, ["get-artifact-version", uuid, "2", "--base-url", BASE_URL]) + assert result.exit_code != 0 + assert "TEA 1.x" in result.output + assert len(responses.calls) == 0 + @responses.activate def test_get_release_product(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" @@ -680,7 +695,7 @@ def test_get_collection_error(self): @responses.activate def test_get_artifact_error(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" - responses.get(f"{BASE_URL}/artifact/{uuid}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{uuid}", status=500) result = runner.invoke(app, ["get-artifact", uuid, "--base-url", BASE_URL]) assert result.exit_code == 1 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 05c2c4d..bad3ced 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -596,8 +596,9 @@ def test_get_component_release_collection_by_version(self, client, base_url): class TestArtifact: @responses.activate def test_get_artifact(self, client, base_url): + # TEA 0.4.0 has no /latest suffix: artifact metadata lives at /artifact/{uuid} responses.get( - f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234/latest", + f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234", json={ "uuid": "e5f6a7b8-c9d0-1234-efab-345678901234", "name": "SBOM", @@ -616,20 +617,10 @@ def test_get_artifact(self, client, base_url): assert artifact.name == "SBOM" @responses.activate - def test_get_artifact_version(self, client, base_url): - responses.get( - f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234/2", - json={ - "uuid": "e5f6a7b8-c9d0-1234-efab-345678901234", - "name": "SBOM", - "type": "BOM", - "version": 2, - "formats": [], - }, - ) - artifact = client.get_artifact_version("e5f6a7b8-c9d0-1234-efab-345678901234", 2) - assert isinstance(artifact, Artifact) - assert artifact.version == 2 + def test_get_artifact_version_requires_tea_1x(self, client): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + client.get_artifact_version("e5f6a7b8-c9d0-1234-efab-345678901234", 2) + assert len(responses.calls) == 0 def test_get_artifact_version_rejects_zero(self, client): with pytest.raises(TeaValidationError, match="Artifact version must be >= 1"): diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py index 8b0f749..c87dc85 100644 --- a/tests/client/test_client_v1.py +++ b/tests/client/test_client_v1.py @@ -152,6 +152,20 @@ def test_v1_artifact_with_md5_rejected(): TeaClient(V1_BASE_URL).get_artifact(ARTIFACT) +@responses.activate +def test_get_artifact_uses_latest_path_on_v1(v1): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest", json=V1_ARTIFACT) + assert v1.get_artifact(ARTIFACT).version == 2 + assert len(responses.calls) == 1 + + +@responses.activate +def test_get_artifact_version_on_v1(v1): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/2", json=V1_ARTIFACT) + assert v1.get_artifact_version(ARTIFACT, 2).version == 2 + assert len(responses.calls) == 1 + + # --- Token pagination --- diff --git a/tests/client/test_iterators.py b/tests/client/test_iterators.py index 0c74d31..6137c08 100644 --- a/tests/client/test_iterators.py +++ b/tests/client/test_iterators.py @@ -381,7 +381,7 @@ def test_get_artifacts_bulk(self): ] for uid in uuids: responses.get( - f"{BASE_URL}/artifact/{uid}/latest", + f"{BASE_URL}/artifact/{uid}", json={"uuid": uid, "name": "SBOM", "type": "BOM", "formats": []}, ) with TeaClient(BASE_URL) as client: diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index ed11f71..d736d8a 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -1068,7 +1068,7 @@ def test_fail_on_server_error(self): class TestCheckGetArtifact: @responses.activate def test_pass(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=_ARTIFACT_JSON) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=_ARTIFACT_JSON) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1083,7 +1083,7 @@ def test_skip_no_uuid(self): @responses.activate def test_fail_on_server_error(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", status=500) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1092,7 +1092,7 @@ def test_fail_on_server_error(self): @responses.activate def test_pass_artifact_without_name(self): artifact_no_name = {"uuid": _ARTIFACT_UUID, "type": "BOM", "formats": []} - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_no_name) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=artifact_no_name) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1464,7 +1464,7 @@ def test_pass_with_formats(self): "type": "BOM", "formats": [{"mediaType": "application/json", "url": "https://example.com/sbom.json"}], } - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_with_formats) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=artifact_with_formats) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx) @@ -1473,7 +1473,7 @@ def test_pass_with_formats(self): @responses.activate def test_warn_no_formats(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=_ARTIFACT_JSON) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=_ARTIFACT_JSON) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx) @@ -1488,7 +1488,7 @@ def test_skip_no_uuid(self): @responses.activate def test_fail_on_server_error(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", status=500) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx)