diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b0b7a64..2de8967 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - run: uv python install ${{ matrix.python-version }} - - run: uv sync + - run: uv sync --extra cli - run: uv run ruff check . - run: uv run ruff format --check . - - run: uv run pytest --cov=libtea --cov-report=term-missing + - run: uv run pytest --cov-fail-under=90 + - run: uv build diff --git a/.github/workflows/pypi.yaml b/.github/workflows/pypi.yaml index 88a9aaf..09c5424 100644 --- a/.github/workflows/pypi.yaml +++ b/.github/workflows/pypi.yaml @@ -53,6 +53,11 @@ jobs: - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + - name: Run tests + run: | + uv sync --extra cli + uv run pytest --cov-fail-under=90 + - name: Build package run: uv build diff --git a/.gitignore b/.gitignore index e91005f..4847e31 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ htmlcov/ nosetests.xml coverage.xml *.cover +*,cover *.py.cover .hypothesis/ .pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89751af..d787830 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,15 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.15.0 hooks: - id: ruff args: [--fix] - id: ruff-format + - repo: local + hooks: + - id: mypy + name: mypy + entry: uv run mypy + language: system + types: [python] + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index e6cdb5d..34256ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,27 +4,90 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**py-libtea** is a Python client library for the Transparency Exchange API (TEA), maintained under the sbomify organization. - -- **License**: Apache 2.0 -- **Repository**: https://github.com/sbomify/py-libtea +**py-libtea** is a hand-crafted Python client library for the Transparency Exchange API (TEA) v0.3.0-beta.2. Consumer-focused (read-only); publisher API is not yet supported (blocked on TEA spec). Licensed Apache 2.0, maintained under sbomify. ## Build & Dev Commands ```bash -uv sync # Install dependencies -uv run pytest # Run tests (with coverage) -uv run ruff check . # Lint -uv run ruff format --check . # Format check -uv build # Build wheel and sdist +uv sync # Install all dependencies +uv run pytest # Run full test suite with coverage +uv run pytest tests/client/test_client.py -v # Run a single test file +uv run pytest tests/unit/test_security.py::TestSsrfProtection::test_rejects_cgnat_ip -v # Single test +uv run mypy # Type check (strict mode) +uv run ruff check . # Lint +uv run ruff format --check . # Format check +uv run ruff format . # Auto-format +uv build # Build wheel and sdist ``` ## Code Conventions -- **Layout**: Flat package layout (`libtea/`) -- **Build backend**: Hatchling -- **Line length**: 120 -- **Lint/Format**: Ruff (rules: E, F, I) -- **Tests**: pytest with pytest-cov, test files in `tests/` -- **Python**: >=3.11 -- **Type checking**: PEP 561 (`py.typed` marker) +- **Layout**: src/ layout (`src/libtea/`), hatchling build backend +- **Python**: >=3.11 (enables `StrEnum`, `X | Y` union syntax) +- **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% + +## Architecture + +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 + ↓ uses +_validation.py Input validation helpers (path segments, page size/offset, 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) +_hashing.py Checksum hash builders (SHA-*, BLAKE2b-*, MD5) +discovery.py TEI parsing, .well-known/tea fetching, SemVer endpoint selection, redirect SSRF protection +models.py Pydantic v2 models for all TEA domain objects (frozen, camelCase aliases) +exceptions.py Exception hierarchy (all inherit from TeaError) +cli.py typer CLI (optional dependency, thin wrapper over TeaClient) +_cli_fmt.py Rich output formatters for all CLI commands (tables, panels, escape helpers) +_cli_entry.py Entry point wrapper that handles missing typer gracefully +``` + +**Key design patterns:** + +- `TeaClient` delegates all HTTP to `TeaHttpClient` — never calls `requests` directly +- Bearer tokens are NOT sent to artifact download URLs (separate unauthenticated session prevents token leakage to CDNs) +- 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 +- `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 +- CLI formatters in `_cli_fmt.py` escape all server-controlled strings with `rich.markup.escape()` to prevent Rich markup injection + +**Auth**: Bearer token, basic auth, and mTLS (via `MtlsConfig` dataclass) are mutually configurable. Token and basic_auth are mutually exclusive. HTTP (non-TLS) with credentials is rejected. + +## Critical Implementation Rules + +- **NEVER** use `from __future__ import annotations` in files containing Pydantic models — it breaks Pydantic v2 runtime type evaluation +- `pydantic >= 2.1.0` is the floor (for `pydantic.alias_generators.to_camel`) +- `requests` auto-encodes query params — do NOT pre-encode with `urllib.parse.quote()` +- When mocking with `responses` library, use `requests.ConnectionError` as the body exception (not Python's built-in `ConnectionError` — they are different classes) +- `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 +- 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 + +The TEA spec repo should be cloned to `/tmp/transparency-exchange-api/` for cross-referencing: + +```bash +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` + +## Design Docs + +- `docs/plans/2025-02-25-tea-client-design.md` — v0.1.0 original design +- `docs/plans/2026-02-25-v0.2.0-design.md` — v0.2.0 (CLE, SemVer, failover, mTLS, CLI) +- `docs/plans/2026-02-26-v0.3.0-design.md` — v0.3.0 (httpx migration, async client) +- `docs/FUTURE.md` — Items blocked on external factors (Publisher API) diff --git a/README.md b/README.md index 2d7da43..b291011 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,14 @@ TEA is an open standard for discovering and retrieving software transparency art - Auto-discovery via `.well-known/tea` and TEI URNs - Products, components, releases, and versioned collections - Search by PURL, CPE, or TEI identifier +- Common Lifecycle Enumeration (CLE) — ECMA-428 lifecycle events - Artifact download with on-the-fly checksum verification (MD5 through BLAKE2b) +- Endpoint failover with SemVer-compatible version selection +- Bearer token, HTTP basic auth, and mutual TLS (mTLS) authentication +- Bearer token isolation — tokens are never sent to artifact download hosts - Typed Pydantic v2 models with full camelCase/snake_case conversion - Structured exception hierarchy with error context -- Bearer token isolation — tokens are never sent to artifact download hosts +- CLI with rich-formatted output and JSON mode ## Installation @@ -29,28 +33,36 @@ TEA is an open standard for discovering and retrieving software transparency art pip install libtea ``` +To include the CLI (`tea-cli`): + +```bash +pip install libtea[cli] +``` + ## Quick start ```python from libtea import TeaClient -# Auto-discover from a domain's .well-known/tea -with TeaClient.from_well_known("example.com", token="your-bearer-token") as client: - # Browse a product - product = client.get_product("product-uuid") - print(product.name) +# Auto-discover the sbomify TEA server from its .well-known/tea +with TeaClient.from_well_known("trust.sbomify.com", token="your-bearer-token") as client: + # Discover a product by TEI + results = client.discover( + "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + ) + for info in results: + print(info.product_release_uuid, info.servers) - # Get a component release with its latest collection - cr = client.get_component_release("release-uuid") - for artifact in cr.latest_collection.artifacts: - print(artifact.name, artifact.type) + # Get a product release + pr = client.get_product_release(results[0].product_release_uuid) + print(pr.version, pr.product_name) ``` Or connect directly to a known endpoint: ```python client = TeaClient( - base_url="https://api.example.com/tea/v0.3.0-beta.2", + base_url="https://trust.sbomify.com/tea/v0.3.0-beta.2", token="your-bearer-token", timeout=30.0, ) @@ -60,7 +72,7 @@ Using `from_well_known`, you can also override the spec version and timeout: ```python client = TeaClient.from_well_known( - "example.com", + "trust.sbomify.com", token="your-bearer-token", timeout=15.0, version="0.3.0-beta.2", # default @@ -69,18 +81,50 @@ client = TeaClient.from_well_known( ## Usage +### Discovery + +```python +from libtea import TeaClient + +# Discover sbomify products via TEI +with TeaClient.from_well_known("trust.sbomify.com") as client: + results = client.discover( + "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + ) + for info in results: + print(info.product_release_uuid, info.servers) +``` + +Low-level discovery functions are also available: + +```python +from libtea.discovery import parse_tei, fetch_well_known, select_endpoint + +# Parse a TEI URN +tei_type, domain, identifier = parse_tei( + "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +) + +# Fetch and select an endpoint manually +well_known = fetch_well_known("trust.sbomify.com") +endpoint = select_endpoint(well_known, "0.3.0-beta.2") +print(endpoint.url, endpoint.priority) +``` + +Supported TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi`. + ### Search ```python -with TeaClient.from_well_known("example.com") as client: +with TeaClient.from_well_known("trust.sbomify.com") as client: # Search by PURL - results = client.search_products("PURL", "pkg:pypi/requests") + results = client.search_products("PURL", "pkg:github/sbomify/sbomify") for product in results.results: print(product.name, product.uuid) # Search product releases (with pagination) releases = client.search_product_releases( - "PURL", "pkg:pypi/requests@2.31.0", + "PURL", "pkg:github/sbomify/sbomify", page_offset=0, page_size=100, ) print(releases.total_results) @@ -89,7 +133,7 @@ with TeaClient.from_well_known("example.com") as client: ### Products and releases ```python -with TeaClient.from_well_known("example.com") as client: +with TeaClient.from_well_known("trust.sbomify.com") as client: product = client.get_product("product-uuid") print(product.name, product.identifiers) @@ -110,7 +154,7 @@ with TeaClient.from_well_known("example.com") as client: ### Components ```python -with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: +with TeaClient.from_well_known("trust.sbomify.com") as client: component = client.get_component("component-uuid") releases = client.get_component_releases("component-uuid") @@ -122,7 +166,7 @@ with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: ### Collections and artifacts ```python -with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: +with TeaClient.from_well_known("trust.sbomify.com") as client: collection = client.get_component_release_collection_latest("release-uuid") for artifact in collection.artifacts: print(artifact.name, artifact.type) @@ -139,7 +183,7 @@ with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: ```python from pathlib import Path -with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: +with TeaClient.from_well_known("trust.sbomify.com") as client: artifact = client.get_artifact("artifact-uuid") fmt = artifact.formats[0] @@ -155,29 +199,170 @@ Supported checksum algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, 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. -### Discovery +### Common Lifecycle Enumeration (CLE) ```python -from libtea.discovery import parse_tei, fetch_well_known, select_endpoint +with TeaClient.from_well_known("trust.sbomify.com") as client: + # Get lifecycle events for a product release + cle = client.get_product_release_cle("release-uuid") + for event in cle.events: + print(event.type, event.effective) + + # CLE is available for all entity types + client.get_product_cle("product-uuid") + client.get_component_cle("component-uuid") + client.get_component_release_cle("release-uuid") +``` -# Parse a TEI URN -tei_type, domain, identifier = parse_tei( - "urn:tei:purl:cyclonedx.org:pkg:pypi/cyclonedx-python-lib@8.4.0" +### Authentication + +```python +from libtea import TeaClient, MtlsConfig +from pathlib import Path + +# Bearer token +client = TeaClient.from_well_known("trust.sbomify.com", token="your-token") + +# HTTP basic auth +client = TeaClient.from_well_known("trust.sbomify.com", basic_auth=("user", "password")) + +# Mutual TLS (mTLS) +client = TeaClient.from_well_known( + "trust.sbomify.com", + mtls=MtlsConfig( + client_cert=Path("client.pem"), + client_key=Path("client-key.pem"), + ca_bundle=Path("ca-bundle.pem"), # optional + ), ) +``` -# Low-level: fetch and select an endpoint manually -well_known = fetch_well_known("example.com") -endpoint = select_endpoint(well_known, "0.3.0-beta.2") -print(endpoint.url, endpoint.priority) +## CLI + +The `tea-cli` command provides a terminal interface for all TEA operations. Install with `pip install libtea[cli]`. See the [full CLI reference](docs/cli.md) for detailed documentation. + +### Global options -# Discover product releases by TEI -with TeaClient(base_url="https://api.example.com/tea/v0.3.0-beta.2") as client: - results = client.discover("urn:tei:uuid:example.com:d4d9f54a-abcf-11ee-ac79-1a52914d44b") - for info in results: - print(info.product_release_uuid, info.servers) +``` +--json Output raw JSON instead of rich-formatted tables +--debug, -d Show debug output (HTTP requests, timing) +--version Show version ``` -Supported TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi`. +All commands accept connection options: `--base-url`, `--domain`, `--token`, `--auth`, `--use-http`, `--port`, `--client-cert`, `--client-key`, `--ca-bundle`. + +### Discover + +```bash +# Discover sbomify product releases via TEI +tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + +# UUIDs only (for scripting) +tea-cli discover -q "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" + +# JSON output +tea-cli --json discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +``` + +### 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" + +# Limit component resolution +tea-cli inspect --max-components 10 "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +``` + +### Search + +```bash +# Search products by PURL +tea-cli search-products --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ + --domain trust.sbomify.com + +# Search product releases +tea-cli search-releases --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ + --domain trust.sbomify.com --page-size 50 +``` + +### Products and releases + +```bash +# Get product details +tea-cli get-product --domain trust.sbomify.com + +# List releases for a product +tea-cli get-product-releases --domain trust.sbomify.com + +# Get a specific release (product or component) +tea-cli get-release --domain trust.sbomify.com +tea-cli get-release --component --domain trust.sbomify.com +``` + +### Components + +```bash +# Get component details +tea-cli get-component --domain trust.sbomify.com + +# List component releases +tea-cli get-component-releases --domain trust.sbomify.com +``` + +### Collections and artifacts + +```bash +# Get latest collection (default) or specific version +tea-cli get-collection --domain trust.sbomify.com +tea-cli get-collection --version 3 --domain trust.sbomify.com + +# List all collection versions +tea-cli list-collections --domain trust.sbomify.com + +# Get artifact metadata +tea-cli get-artifact --domain trust.sbomify.com +``` + +### Download + +```bash +# Download an artifact with checksum verification +tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ + --checksum "SHA-256:abc123..." \ + --domain trust.sbomify.com +``` + +### Lifecycle (CLE) + +```bash +# Get lifecycle events for different entity types +tea-cli get-cle --entity product-release --domain trust.sbomify.com +tea-cli get-cle --entity product --domain trust.sbomify.com +tea-cli get-cle --entity component --domain trust.sbomify.com +tea-cli get-cle --entity component-release --domain trust.sbomify.com +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TEA_BASE_URL` | TEA server base URL (alternative to `--base-url`) | +| `TEA_TOKEN` | Bearer token (alternative to `--token`) | +| `TEA_AUTH` | Basic auth as `USER:PASSWORD` (alternative to `--auth`) | + +### Shell completion + +```bash +# Bash +tea-cli --install-completion bash + +# Zsh +tea-cli --install-completion zsh + +# Fish +tea-cli --install-completion fish +``` ## Error handling @@ -213,16 +398,16 @@ Using a bearer token over plaintext HTTP raises `ValueError` immediately — HTT ## Requirements - Python >= 3.11 -- [requests](https://requests.readthedocs.io/) >= 2.31.0 for HTTP +- [requests](https://requests.readthedocs.io/) >= 2.32.4 for HTTP - [Pydantic](https://docs.pydantic.dev/) >= 2.1.0 for data models +- [semver](https://python-semver.readthedocs.io/) >= 3.0.4 for version selection + +Optional (for CLI): [typer](https://typer.tiangolo.com/) >= 0.12.0, [rich](https://rich.readthedocs.io/) >= 13.0.0 ## Not yet supported - Publisher API (spec is consumer-only in beta.2) -- Async client -- CLE (Common Lifecycle Enumeration) endpoints -- Mutual TLS (mTLS) authentication -- Endpoint failover with retry +- Async client (httpx migration) ## Development diff --git a/docs/FUTURE.md b/docs/FUTURE.md new file mode 100644 index 0000000..5d3f33f --- /dev/null +++ b/docs/FUTURE.md @@ -0,0 +1,23 @@ +# py-libtea Future Roadmap + +Items that depend on external factors or are deferred indefinitely. These are **not** scheduled for any release — they move to a versioned plan when their blockers are resolved. + +--- + +## Publisher API + +**Blocked on:** TEA spec stability. The Publisher API is currently v0.0.2 draft with significant naming mismatches against the consumer API (`leaf` vs `release`, `tei_urn` vs `uuid`). Schema is not stable enough to build against. + +**What we'll need when the spec stabilizes:** + +| Item | Notes | +|------|-------| +| Publisher endpoints (POST/PUT/DELETE for products, releases, components, artifacts) | Mirror consumer API shape | +| Artifact upload with streaming + checksum | Reverse of `download_artifact` | +| Publisher-specific models | Likely share base types with consumer models | +| Publisher auth (token scoping, write permissions) | May differ from consumer bearer token | +| CLI `publish` / `upload` commands | Counterpart to existing `discover`, `get`, `download` | + +**TEA spec tracking:** [CycloneDX/transparency-exchange-api](https://github.com/CycloneDX/transparency-exchange-api) + +**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. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..164785b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,361 @@ +# tea-cli Reference + +Command-line client for the Transparency Exchange API (TEA). + +## Synopsis + +``` +tea-cli [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] +``` + +## Description + +`tea-cli` is a command-line interface for the Transparency Exchange API (TEA) v0.3.0-beta.2. 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. + +`tea-cli` is part of the [libtea](https://github.com/sbomify/py-libtea) Python library, maintained by sbomify. + +## Installation + +```bash +pip install libtea[cli] +``` + +## Global Options + +| Option | Description | +|--------|-------------| +| `--json` | Output raw JSON instead of rich-formatted tables | +| `--debug`, `-d` | Show debug output (HTTP requests, timing) on stderr | +| `--version` | Show version and exit | +| `--help` | Show help message and exit | + +## Connection Options + +Every command accepts the following options for server selection and authentication. + +| Option | Description | +|--------|-------------| +| `--base-url` *URL* | TEA server base URL (e.g. `https://trust.sbomify.com/tea/v0.3.0-beta.2`). 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. | +| `--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`. | +| `--client-cert` *PATH* | Path to client certificate for mutual TLS (mTLS). Must be used with `--client-key`. | +| `--client-key` *PATH* | Path to client private key for mTLS. Must be used with `--client-cert`. | +| `--ca-bundle` *PATH* | Path to CA bundle for mTLS server verification. | +| `--timeout` *SECONDS* | Request timeout in seconds (default: 30). | +| `--use-http` | Use HTTP instead of HTTPS for `.well-known/tea` discovery. Intended for local development only. | +| `--port` *PORT* | Port for well-known resolution (overrides the default for the scheme). | + +## Commands + +### discover + +Resolve a TEI URN to product release UUID(s). + +``` +tea-cli discover [--quiet | -q] TEI +``` + +**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. | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--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. + +``` +tea-cli inspect [--max-components N] TEI +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--max-components` *N* | Maximum number of components to fetch per release (default: 50). | + +--- + +### search-products + +Search for products by identifier. + +``` +tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--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). | + +--- + +### search-releases + +Search for product releases by identifier. + +``` +tea-cli search-releases --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +``` + +Options are the same as `search-products`. + +--- + +### get-product + +Get a product by UUID. + +``` +tea-cli get-product UUID +``` + +--- + +### get-product-releases + +List releases for a product UUID. + +``` +tea-cli get-product-releases UUID [--page-offset N] [--page-size N] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--page-offset` *N* | Page offset for pagination (default: 0). | +| `--page-size` *N* | Page size for pagination (default: 100). | + +--- + +### get-release + +Get a product or component release by UUID. + +``` +tea-cli get-release [--component] UUID +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--component` | Get a component release instead of a product release. | + +--- + +### get-component + +Get a component by UUID. + +``` +tea-cli get-component UUID +``` + +--- + +### get-component-releases + +List releases for a component UUID. + +``` +tea-cli get-component-releases UUID +``` + +--- + +### get-collection + +Get a collection (latest or by version). + +``` +tea-cli get-collection [--version N] [--component] UUID +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--version` *N* | Collection version number. If omitted, returns the latest collection. | +| `--component` | Get from component release instead of product release. | + +--- + +### list-collections + +List all collection versions for a release UUID. + +``` +tea-cli list-collections [--component] UUID +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--component` | List collections for a component release instead of a product release. | + +--- + +### get-artifact + +Get artifact metadata by UUID. + +``` +tea-cli get-artifact UUID +``` + +--- + +### download + +Download an artifact file with optional checksum verification. + +``` +tea-cli download URL DEST [--checksum ALG:VALUE]... [--max-download-bytes N] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| *URL* | Artifact download URL. | +| *DEST* | Local file path to save the downloaded artifact. | + +**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. | +| `--max-download-bytes` *N* | Maximum download size in bytes. The download is aborted if the response exceeds this limit. | + +--- + +### get-cle + +Get Common Lifecycle Enumeration (CLE) events for an entity. + +``` +tea-cli get-cle [--entity TYPE] UUID +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--entity` *TYPE* | Entity type: `product`, `product-release` (default), `component`, or `component-release`. | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `TEA_BASE_URL` | TEA server base URL. Equivalent to `--base-url`. | +| `TEA_TOKEN` | Bearer token. Equivalent to `--token`. Preferred over the command-line flag to avoid exposing the token in shell history. | +| `TEA_AUTH` | Basic auth credentials as `USER:PASSWORD`. Equivalent to `--auth`. | + +## Authentication + +`tea-cli` supports three authentication methods, all mutually exclusive: + +**Bearer token** (most common): Pass via `--token` or `TEA_TOKEN`. Requires HTTPS. The token is never sent to artifact download URLs (CDNs) — only to the TEA API server. + +**HTTP basic auth:** Pass via `--auth USER:PASSWORD` or `TEA_AUTH`. Requires HTTPS. + +**Mutual TLS (mTLS):** Pass certificate and key via `--client-cert` and `--client-key`. Optionally provide a CA bundle with `--ca-bundle`. + +Using credentials over plaintext HTTP raises an error. Use `--use-http` only for unauthenticated local development. + +## Examples + +Discover sbomify product releases: + +```bash +tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +``` + +Full inspection with JSON output: + +```bash +tea-cli --json inspect "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +``` + +Search for a product by PURL: + +```bash +tea-cli search-products \ + --id-type PURL \ + --id-value "pkg:github/sbomify/sbomify" \ + --domain trust.sbomify.com +``` + +Get lifecycle events for a product release: + +```bash +tea-cli get-cle --entity product-release \ + --domain trust.sbomify.com +``` + +Download an artifact with checksum verification: + +```bash +tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ + --checksum "SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" \ + --domain trust.sbomify.com +``` + +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 +``` + +Using environment variables to avoid repeating credentials: + +```bash +export TEA_TOKEN="your-bearer-token" +export TEA_BASE_URL="https://trust.sbomify.com/tea/v0.3.0-beta.2" +tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli get-product +``` + +Mutual TLS authentication: + +```bash +tea-cli get-product \ + --domain trust.sbomify.com \ + --client-cert client.pem \ + --client-key client-key.pem \ + --ca-bundle ca-bundle.pem +``` + +## Exit Status + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Error (network failure, authentication error, not found, invalid input, etc.). The error message is printed to stderr. | + +## See Also + +- [Transparency Exchange API](https://transparency.exchange/) +- [py-libtea on GitHub](https://github.com/sbomify/py-libtea) +- [TEA specification](https://github.com/CycloneDX/transparency-exchange-api) diff --git a/docs/plans/2025-02-25-tea-client-design.md b/docs/plans/2025-02-25-tea-client-design.md new file mode 100644 index 0000000..8f4e5ae --- /dev/null +++ b/docs/plans/2025-02-25-tea-client-design.md @@ -0,0 +1,218 @@ +# TEA Client Library Design - v0.1.0 + +## Overview + +py-libtea is a Python client library for the Transparency Exchange API (TEA) v0.3.0-beta.2. +This document covers the design for the initial v0.1.0 release targeting consumer-side functionality. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Approach | Hand-crafted client | Auto-generated client already possible via CycloneDX's openapi-generator config; hand-crafted provides Pythonic ergonomics and implements discovery flow which generators can't | +| HTTP client | requests | Battle-tested, widely adopted, minimal dependency footprint | +| Data models | Pydantic v2 (>= 2.1) | Automatic JSON deserialization, validation, great editor support. Floor is 2.1+ because `pydantic.alias_generators.to_camel` was introduced in 2.1 | +| Sync/Async | Sync only (v0.1.0) | Primary consumers are CLI tools and CI pipelines; async is additive and can be introduced later without breaking changes | +| Python | >= 3.11 | Matches existing pyproject.toml constraint; enables `StrEnum` and modern type syntax | + +## Scope + +### In scope (v0.1.0) + +- TEI parsing and validation +- `.well-known/tea` discovery (fetch and parse) +- Endpoint selection by version and priority (exact string match) +- Product browsing: get product by UUID +- Product Release: get by UUID, get latest collection +- Component browsing: get component by UUID, get releases +- Component Release: get by UUID, get latest collection, get specific collection version +- Collection access: list collections, get by version +- Artifact metadata: get artifact by UUID +- Artifact download with checksum verification +- Bearer token authentication +- Error handling with typed exceptions (all errors are `TeaError` subclasses) + +### Deferred + +- CLE (Common Lifecycle Enumeration) endpoints and models (4 endpoints, 6 schemas in spec) +- Query/search endpoints (`/products`, `/productReleases` with identifier filters) +- Full DNS-based TEI resolution (v0.1.0 uses direct base URL or `.well-known` only) +- SemVer-based version matching in endpoint selection (v0.1.0 uses exact string match) +- Endpoint failover with retry/backoff on 5xx/DNS/TLS failures (spec MUST requirement) +- Basic auth (`basicAuth` security scheme in spec) and mTLS authentication +- Async client (`AsyncTeaClient`) +- Pagination auto-iteration +- Publisher API (blocked on TEA spec — see `docs/FUTURE.md`) + +### Known Limitations (v0.1.0) + +- Endpoint selection uses exact string match, not SemVer 2.0.0 comparison as spec recommends +- No endpoint failover: if the selected endpoint fails, the error propagates to the caller +- `fetch_well_known` uses a standalone `requests.get()` call, not routed through `_http.py` (no User-Agent or retry). This is intentional since `.well-known` is typically public +- BLAKE3 checksum is in the enum but not supported at runtime (Python's `hashlib` does not include it). A clear `TeaChecksumError` is raised if a server provides a BLAKE3-only checksum + +## Architecture + +### Package Structure + +``` +libtea/ + __init__.py # Public API exports, __version__ + py.typed # PEP 561 marker + client.py # TeaClient - main entry point + discovery.py # TEI parsing, .well-known fetching, endpoint selection + models.py # Pydantic models for all TEA domain objects + exceptions.py # Exception hierarchy + _http.py # Internal requests wrapper (session management, auth, error mapping) +``` + +### Data Models (models.py) + +All models use Pydantic v2 `BaseModel` with `alias_generator=to_camel` for camelCase JSON mapping and `populate_by_name=True` for snake_case Python access. All enums use `StrEnum` (Python 3.11+). Models do NOT use `from __future__ import annotations` (breaks Pydantic v2 runtime type evaluation). + +**Shared types:** +- `Identifier` - idType (enum: CPE, TEI, PURL) + idValue +- `Checksum` - algType (enum) + algValue, with `@field_validator` to normalize both hyphen (`SHA-256`) and underscore (`SHA_256`) forms from servers +- `ChecksumAlgorithm` - enum: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512, BLAKE3 +- `IdentifierType` - enum: CPE, TEI, PURL + +**Domain objects:** +- `Product` - uuid, name, identifiers +- `ProductRelease` - uuid, product, productName, version, createdDate, releaseDate, preRelease, identifiers, components +- `ComponentRef` - uuid, release (optional) +- `Component` - uuid, name, identifiers +- `Release` (Component Release) - uuid, component, componentName, version, createdDate, releaseDate, preRelease, identifiers, distributions +- `ReleaseDistribution` - distributionType, description, identifiers, url, signatureUrl, checksums +- `ComponentReleaseWithCollection` - release, latestCollection +- `Collection` - uuid, version, date, belongsTo, updateReason, artifacts +- `CollectionBelongsTo` - enum: COMPONENT_RELEASE, PRODUCT_RELEASE +- `CollectionUpdateReason` - type (enum) + comment +- `CollectionUpdateReasonType` - enum: INITIAL_RELEASE, VEX_UPDATED, ARTIFACT_UPDATED, ARTIFACT_ADDED, ARTIFACT_REMOVED +- `Artifact` - uuid, name, type (enum), distributionTypes, formats +- `ArtifactType` - enum: ATTESTATION, BOM, BUILD_META, CERTIFICATION, FORMULATION, LICENSE, RELEASE_NOTES, SECURITY_TXT, THREAT_MODEL, VULNERABILITIES, OTHER +- `ArtifactFormat` - mediaType, description, url, signatureUrl, checksums + +**Discovery types:** +- `TeaWellKnown` - schemaVersion (`Literal[1]`), endpoints +- `TeaEndpoint` - url, versions, priority +- `DiscoveryInfo` - productReleaseUuid, servers +- `TeaServerInfo` - rootUrl, versions, priority + +**Pagination:** +- `PaginationDetails` - timestamp, pageStartIndex, pageSize, totalResults +- `PaginatedProductResponse` - pagination fields + results (list of Product) +- `PaginatedProductReleaseResponse` - pagination fields + results (list of ProductRelease) + +**Error:** +- `ErrorResponse` - error (enum: OBJECT_UNKNOWN, OBJECT_NOT_SHAREABLE) + +### Client API (client.py) + +```python +class TeaClient: + def __init__(self, base_url: str, *, token: str | None = None, timeout: float = 30.0): ... + + # Discovery + @classmethod + def from_well_known(cls, domain: str, *, token: str | None = None) -> "TeaClient": ... + def discover(self, tei: str) -> list[DiscoveryInfo]: ... + + # Products + def get_product(self, uuid: str) -> Product: ... + def get_product_releases(self, uuid: str, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductReleaseResponse: ... + + # Product Releases + def get_product_release(self, uuid: str) -> ProductRelease: ... + def get_product_release_collection_latest(self, uuid: str) -> Collection: ... + def get_product_release_collections(self, uuid: str) -> list[Collection]: ... + def get_product_release_collection(self, uuid: str, version: int) -> Collection: ... + + # Components + def get_component(self, uuid: str) -> Component: ... + def get_component_releases(self, uuid: str) -> list[Release]: ... + + # Component Releases + def get_component_release(self, uuid: str) -> ComponentReleaseWithCollection: ... + def get_component_release_collection_latest(self, uuid: str) -> Collection: ... + def get_component_release_collections(self, uuid: str) -> list[Collection]: ... + def get_component_release_collection(self, uuid: str, version: int) -> Collection: ... + + # Artifacts + def get_artifact(self, uuid: str) -> Artifact: ... + def download_artifact(self, url: str, dest: Path, *, verify_checksums: list[Checksum] | None = None) -> Path: ... +``` + +### Discovery (discovery.py) + +```python +def parse_tei(tei: str) -> tuple[str, str, str]: + """Parse TEI URN into (type, domain, identifier).""" + +def fetch_well_known(domain: str, *, timeout: float = 10.0) -> TeaWellKnown: + """Fetch and parse .well-known/tea from domain via HTTPS.""" + +def select_endpoint(well_known: TeaWellKnown, supported_version: str) -> TeaEndpoint: + """Select best endpoint by version match and priority.""" +``` + +### Exception Hierarchy (exceptions.py) + +``` +TeaError (base) + TeaConnectionError - network/connection failures + TeaAuthenticationError - 401/403 responses + TeaNotFoundError - 404 responses, with error_type: ErrorType | None + TeaRequestError - 400 and other client errors + TeaServerError - 5xx responses + TeaDiscoveryError - discovery-specific failures (bad TEI, no .well-known, no compatible endpoint) + TeaChecksumError - checksum verification failure on artifact download + TeaValidationError - malformed server response that fails Pydantic validation +``` + +All exceptions from the client are `TeaError` subclasses. Raw `pydantic.ValidationError` from malformed server responses is caught and wrapped in `TeaValidationError`. + +### Internal HTTP Layer (_http.py) + +- Wraps `requests.Session` with base URL, auth headers, timeout, and user-agent +- Maps HTTP status codes to typed exceptions +- Handles JSON deserialization via Pydantic models +- User-Agent follows `py-libtea/{version} (hello@sbomify.com)` pattern matching sbomify-action + +## Authentication + +v0.1.0 supports bearer token auth only. The token is passed at client construction and sent as `Authorization: Bearer ` on API requests to the configured base URL. The token is NOT forwarded to artifact download URLs (which may be on third-party hosts like CDNs). A separate unauthenticated `requests.Session` is used for downloads. Unauthenticated access (no token) is also supported for public TEA servers. + +## Error Handling + +- All API errors raise typed exceptions from the hierarchy above +- HTTP 404 with TEA error body (`OBJECT_UNKNOWN`, `OBJECT_NOT_SHAREABLE`) is parsed into the exception +- Network errors are wrapped in `TeaConnectionError` +- Auth errors (401, 403) raise `TeaAuthenticationError` (no failover per spec) + +## Checksum Verification + +`download_artifact` streams the artifact to disk and computes checksums on-the-fly. If `verify_checksums` is provided, the computed hash is compared after download. On mismatch, the file is deleted and `TeaChecksumError` is raised. + +Supported algorithms map directly to Python's `hashlib`: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. BLAKE3 is defined in the enum for spec completeness but is NOT supported at runtime (not in stdlib `hashlib`). If a server provides only BLAKE3 checksums, a `TeaChecksumError` is raised with a clear message. Checksum hex values are compared case-insensitively. + +## Dependencies + +**Runtime:** +- `requests` >= 2.31.0 - HTTP client +- `pydantic` >= 2.1.0 - data models (2.1+ required for `to_camel` alias generator) + +**Dev:** +- `pytest`, `pytest-cov` (already configured) +- `ruff` (already configured) +- `responses` - mock requests for testing + +## Testing Strategy + +- Shared fixtures in `tests/conftest.py` (base URL, client, http_client with yield cleanup) +- Unit tests for TEI parsing, endpoint selection, `.well-known` fetching, model deserialization +- Unit tests with mocked HTTP (responses) for all client methods including error paths +- Unit tests for checksum verification (valid, mismatch, case sensitivity, unsupported algorithm) +- Tests for optional field handling (minimal required-only payloads) +- Tests for `from_well_known` classmethod and token forwarding +- Integration test fixtures with example JSON from the TEA spec +- All tests run via `uv run pytest` diff --git a/docs/plans/2026-02-25-v0.2.0-design.md b/docs/plans/2026-02-25-v0.2.0-design.md new file mode 100644 index 0000000..2f7790a --- /dev/null +++ b/docs/plans/2026-02-25-v0.2.0-design.md @@ -0,0 +1,745 @@ +# py-libtea v0.2.0 Design Document + +**Goal:** Bring py-libtea to full TEA v0.3.0-beta.2 spec compliance by adding CLE endpoints, SemVer-based endpoint selection, endpoint failover with retry, and mTLS/basic auth support. + +**Spec version:** TEA v0.3.0-beta.2 (OpenAPI 3.1.1) — [Ecma TC54-TG1](https://tc54.org/tea/) | [GitHub](https://github.com/CycloneDX/transparency-exchange-api) + +**CLE spec:** ECMA-428 v1.0.0 — [Ecma International](https://ecma-international.org/publications-and-standards/standards/ecma-428/) + +--- + +## Scope + +### In scope (v0.2.0) + +| Feature | Spec requirement | Effort | +|---------|-----------------|--------| +| CLE endpoints (4 new) + models (6 new) | Spec-defined, added Feb 2026 | Medium | +| SemVer version matching in endpoint selection | Spec: "MUST prioritize ... based on SemVer 2.0.0" | Small | +| Endpoint failover with exponential backoff | Spec: "MUST retry ... with the next endpoint" | Medium | +| mTLS support (client certificates) | Spec: one of two auth methods | Small | +| Basic auth support | Spec: defined in OpenAPI security schemes | Small | +| CLI (`tea-cli`) via typer | User-facing tool, like rearm's `tea` subcommand | Medium | +| Remove all regexes | Code quality: replace with plain string operations | Small | +| `fetch_well_known` scheme/port params | Parity with rearm's `--usehttp`/`--useport` | Small | +| `DiscoveryInfo.servers` `min_length=1` | Spec: `minItems: 1` | Trivial | +| `IdentifierType.UDI` disclaimer | UDI not in spec's `identifier-type` enum | Trivial | + +### Out of scope (deferred) + +| Feature | Reason | +|---------|--------| +| Async client (`AsyncTeaClient` via httpx) | Deferred to v0.3.0 | +| Publisher API | Blocked on TEA spec — see `docs/FUTURE.md` | +| Pagination auto-iteration | Convenience, not spec-required | +| Interactive disambiguation | Future CLI enhancement | + +--- + +## 1. CLE (Common Lifecycle Enumeration) + +### Endpoints + +Four new GET endpoints, no pagination, no query parameters: + +| Path | Method | Client method | Return type | +|------|--------|---------------|-------------| +| `/product/{uuid}/cle` | GET | `get_product_cle(uuid)` | `CLE` | +| `/productRelease/{uuid}/cle` | GET | `get_product_release_cle(uuid)` | `CLE` | +| `/component/{uuid}/cle` | GET | `get_component_cle(uuid)` | `CLE` | +| `/componentRelease/{uuid}/cle` | GET | `get_component_release_cle(uuid)` | `CLE` | + +All return the same `CLE` schema. HTTP status codes: 200, 400, 404. + +Design decision: CLE was moved to dedicated endpoints (not nested in objects) because CLE documents can grow large. See [PR #213](https://github.com/CycloneDX/transparency-exchange-api/pull/213), decided at TC54-TG1 meeting 2026-02-19. + +### Data models + +Add to `libtea/models.py`: + +#### `CLEEventType` (StrEnum) + +```python +class CLEEventType(StrEnum): + RELEASED = "released" + END_OF_DEVELOPMENT = "endOfDevelopment" + END_OF_SUPPORT = "endOfSupport" + END_OF_LIFE = "endOfLife" + END_OF_DISTRIBUTION = "endOfDistribution" + END_OF_MARKETING = "endOfMarketing" + SUPERSEDED_BY = "supersededBy" + COMPONENT_RENAMED = "componentRenamed" + WITHDRAWN = "withdrawn" +``` + +Note: CLE event types are **camelCase strings** (not UPPER_SNAKE_CASE like other TEA enums). This is per ECMA-428 spec. + +#### `CLEVersionSpecifier` + +```python +class CLEVersionSpecifier(_TeaModel): + version: str | None = None # Specific version (e.g. "1.0.0") + range: str | None = None # vers format range (e.g. "vers:npm/>=1.0.0|<2.0.0") +``` + +At least one of `version` or `range` should be present. + +#### `CLEEvent` + +```python +class CLEEvent(_TeaModel): + # Required + id: int + type: CLEEventType + effective: datetime + published: datetime + + # Optional — contextual based on event type + version: str | None = None # released + versions: list[CLEVersionSpecifier] | None = None # endOf*, supersededBy + support_id: str | None = None # endOfDevelopment, endOfSupport, endOfLife + license: str | None = None # released + superseded_by_version: str | None = None # supersededBy + identifiers: list[Identifier] | None = None # componentRenamed + event_id: int | None = None # withdrawn + reason: str | None = None # withdrawn + description: str | None = None # any event + references: list[str] | None = None # any event (URIs) +``` + +#### `CLESupportDefinition` + +```python +class CLESupportDefinition(_TeaModel): + id: str # Required + description: str # Required + url: str | None = None # Optional +``` + +#### `CLEDefinitions` + +```python +class CLEDefinitions(_TeaModel): + support: list[CLESupportDefinition] | None = None +``` + +#### `CLE` + +```python +class CLE(_TeaModel): + events: list[CLEEvent] # Required, ordered by id descending + definitions: CLEDefinitions | None = None # Optional +``` + +### Event type ↔ field usage matrix + +| Event type | version | versions | support_id | license | superseded_by_version | identifiers | event_id | reason | +|---|---|---|---|---|---|---|---|---| +| released | X | | | X | | | | | +| endOfDevelopment | | X | X | | | | | | +| endOfSupport | | X | X | | | | | | +| endOfLife | | X | X | | | | | | +| endOfDistribution | | X | | | | | | | +| endOfMarketing | | X | | | | | | | +| supersededBy | | X | | | X | | | | +| componentRenamed | | | | | | X | | | +| withdrawn | | | | | | | X | X | + +### Example response + +```json +{ + "events": [ + { + "id": 3, + "type": "endOfSupport", + "effective": "2025-06-01T00:00:00Z", + "published": "2025-01-01T00:00:00Z", + "versions": [{"range": "vers:npm/>=1.0.0|<2.0.0"}], + "supportId": "standard" + }, + { + "id": 2, + "type": "endOfDevelopment", + "effective": "2025-01-01T00:00:00Z", + "published": "2024-06-01T00:00:00Z", + "versions": [{"version": "1.0.0"}], + "supportId": "standard" + }, + { + "id": 1, + "type": "released", + "effective": "2024-01-01T00:00:00Z", + "published": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "license": "Apache-2.0" + } + ], + "definitions": { + "support": [ + { + "id": "standard", + "description": "Standard product support policy", + "url": "https://example.com/support/standard" + } + ] + } +} +``` + +### Testing strategy + +- Model tests: Validate all 9 event types parse from JSON, round-trip camelCase ↔ snake_case +- Client tests: Mock each of the 4 CLE endpoints, test 404/400 handling +- Edge cases: Empty events array, missing optional definitions, withdrawn event referencing another event + +--- + +## 2. SemVer Version Matching + +### Current behavior + +`select_endpoint()` uses exact string matching: `supported_version in ep.versions`. This violates the spec. + +### Spec requirement + +> "The client MUST prioritize endpoints with the highest matching version supported both by the client and the endpoint based on SemVer 2.0.0 specification comparison rules." + +Source: `discovery/readme.md`, lines 287-295 + +### New dependency + +```toml +semver >= 3.0.4, < 4 +``` + +The `semver` package (python-semver) implements strict SemVer 2.0.0 comparison. The `packaging.version` module uses PEP 440, which is incompatible with SemVer pre-release syntax (e.g., `0.3.0-beta.2`). + +### Implementation + +Replace `select_endpoint()` in `discovery.py`: + +```python +from semver import Version + +def select_endpoint(well_known: TeaWellKnown, supported_version: str) -> TeaEndpoint: + """Select the best endpoint supporting the given version. + + Uses SemVer 2.0.0 comparison per spec. Exact version match required + (pre-releases are distinct from releases per SemVer rules). + Among matching endpoints, selects highest priority. + """ + target = Version.parse(supported_version) + candidates = [] + for ep in well_known.endpoints: + for v in ep.versions: + try: + if Version.parse(v) == target: + candidates.append(ep) + break + except ValueError: + continue # Skip malformed versions + + if not candidates: + available = {v for ep in well_known.endpoints for v in ep.versions} + raise TeaDiscoveryError( + f"No compatible endpoint found for version {supported_version!r}. " + f"Available versions: {sorted(available)}" + ) + + candidates.sort( + key=lambda ep: ep.priority if ep.priority is not None else 1.0, + reverse=True, + ) + return candidates[0] +``` + +Note: For v0.2.0, we use **exact SemVer equality** (the client asks for `0.3.0-beta.2`, server must advertise exactly that). Range-based compatibility matching (e.g., "any 0.3.x") is a future consideration. The spec says "highest matching version supported both by the client and the endpoint" — implying exact match semantics. + +### Testing strategy + +- Exact version matches work +- Pre-release versions are distinct from releases (`0.3.0-beta.2` != `0.3.0`) +- Malformed version strings in server response are skipped gracefully +- Priority ordering preserved +- Existing tests continue to pass (version strings like `"1.0.0"` are valid SemVer) + +--- + +## 3. Endpoint Failover with Retry + +### Spec requirements + +From `discovery/readme.md`, lines 319-331: + +**Failover triggers (YES — retry next endpoint):** +- DNS resolution failure +- HTTP 5xx status codes +- TLS certificate validation failure + +**No failover (STOP and report to user):** +- HTTP 401 Unauthorized +- HTTP 403 Forbidden + +**Retry strategy:** +> "The client SHOULD implement an exponential backoff strategy for retries." + +### Implementation approach + +Use `urllib3.Retry` with `requests.adapters.HTTPAdapter`. This requires **zero new dependencies** (urllib3 is a transitive dependency of requests). + +```python +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +retry = Retry( + total=3, + backoff_factor=0.5, # delays: 0s, 1s, 2s + status_forcelist=(500, 502, 503, 504), # 5xx failover + allowed_methods=["GET", "HEAD", "OPTIONS"], # safe methods only + raise_on_status=False, # let _raise_for_status handle it +) +adapter = HTTPAdapter(max_retries=retry) +session.mount("https://", adapter) +session.mount("http://", adapter) +``` + +### Multi-endpoint failover + +Beyond single-endpoint retry, the spec requires trying the **next endpoint** on failure. This is a higher-level concern than urllib3.Retry. + +New logic in `TeaClient.from_well_known()`: + +```python +@classmethod +def from_well_known(cls, domain, *, token=None, timeout=30.0, version=TEA_SPEC_VERSION): + well_known = fetch_well_known(domain, timeout=timeout) + candidates = _get_sorted_endpoints(well_known, version) + + last_error = None + for endpoint in candidates: + base_url = f"{endpoint.url.rstrip('/')}/v{version}" + try: + client = cls(base_url=base_url, token=token, timeout=timeout) + # Optionally verify endpoint is reachable (light health check) + return client + except (TeaConnectionError, TeaServerError) as exc: + last_error = exc + continue # Try next endpoint + + if last_error: + raise last_error + raise TeaDiscoveryError(f"No compatible endpoint found for version {version!r}") +``` + +### Configuration + +Add optional retry parameters to `TeaHttpClient`: + +```python +class TeaHttpClient: + def __init__( + self, + base_url: str, + *, + token: str | None = None, + timeout: float = 30.0, + max_retries: int = 3, + backoff_factor: float = 0.5, + ): +``` + +### Testing strategy + +- Mock 5xx responses to verify retry behavior +- Mock sequential endpoint failures to verify failover +- Verify 401/403 does NOT trigger failover +- Verify retry count limits +- Verify backoff delay (using time mocking or fast tests) + +--- + +## 4. mTLS Support + +### Spec context + +From `auth/readme.md`: + +> "Two methods are supported: HTTP Bearer Token Authentication and Mutual TLS with verifiable client and server certificates." + +### Implementation + +The `requests` library has built-in mTLS support via the `cert` parameter. No new dependencies. + +```python +from dataclasses import dataclass +from pathlib import Path + +@dataclass(frozen=True) +class MtlsConfig: + """Client certificate configuration for mutual TLS.""" + client_cert: Path + client_key: Path + ca_bundle: Path | None = None # None = use system CA store +``` + +Add to `TeaHttpClient.__init__()`: + +```python +def __init__( + self, + base_url: str, + *, + token: str | None = None, + timeout: float = 30.0, + mtls: MtlsConfig | None = None, +): + # ... existing setup ... + if mtls: + self._session.cert = (str(mtls.client_cert), str(mtls.client_key)) + if mtls.ca_bundle: + self._session.verify = str(mtls.ca_bundle) +``` + +Surface in `TeaClient`: + +```python +class TeaClient: + def __init__(self, base_url, *, token=None, timeout=30.0, mtls=None): + self._http = TeaHttpClient(base_url=base_url, token=token, timeout=timeout, mtls=mtls) +``` + +### Limitation + +`requests` does not support encrypted (password-protected) private keys. If needed, httpx migration (v0.3.0) will unlock `ssl.SSLContext` support with passwords. + +### Testing strategy + +- Verify cert/key paths are set on session +- Verify ca_bundle overrides session.verify +- Verify None ca_bundle leaves default system CA + +--- + +## 5. Basic Auth Support + +### Spec context + +The OpenAPI spec defines both `bearerAuth` and `basicAuth` at the global security level: + +```yaml +security: + - bearerAuth: [] + - basicAuth: [] +``` + +### Implementation + +Add `basic_auth` parameter as alternative to `token`: + +```python +class TeaHttpClient: + def __init__( + self, + base_url: str, + *, + token: str | None = None, + basic_auth: tuple[str, str] | None = None, # (username, password) + timeout: float = 30.0, + mtls: MtlsConfig | None = None, + ): + # ... existing setup ... + if token and basic_auth: + raise ValueError("Cannot use both token and basic_auth") + if token: + self._session.headers["authorization"] = f"Bearer {token}" + elif basic_auth: + self._session.auth = basic_auth +``` + +### Testing strategy + +- Verify basic auth header is sent +- Verify mutual exclusion (token + basic_auth raises ValueError) +- Verify request includes `Authorization: Basic ...` header + +--- + +## 6. CLI (`tea-cli`) + +### Overview + +A command-line interface for the TEA consumer API, packaged as an optional extra (`libtea[cli]`). Uses typer for the CLI framework. The CLI is a thin layer that calls existing `TeaClient` methods and prints JSON to stdout. + +### Installation + +- `pip install libtea` — library only, no typer +- `pip install libtea[cli]` — library + CLI +- Running `tea-cli` without `[cli]` extra shows: `Error: CLI dependencies not installed. Run: pip install libtea[cli]` + +### Entry point + +```toml +[project.scripts] +tea-cli = "libtea.cli:app" + +[project.optional-dependencies] +cli = ["typer>=0.12.0,<1"] +``` + +### File structure + +Single module: `libtea/cli.py`. No business logic — just argument parsing, client construction, and JSON output. + +### Global options + +``` +tea-cli [OPTIONS] COMMAND + +Options: + --base-url URL TEA server base URL (or env: TEA_BASE_URL) + --token TOKEN Bearer token (or env: TEA_TOKEN) + --domain DOMAIN Discover server from domain's .well-known/tea + --timeout FLOAT Request timeout in seconds [default: 30.0] + --use-http Use HTTP instead of HTTPS for discovery [default: false] + --port INT Port for well-known resolution [default: 443/80] + --version Show version + --help Show help +``` + +**Server resolution** — mutually exclusive: +- `--base-url` → use directly +- `--domain` → discover via `.well-known/tea` +- Neither → error with guidance message + +**Auth resolution order:** +1. `--token` flag +2. `TEA_TOKEN` env var +3. No auth (public access) + +### Commands + +| Command | Description | Key arguments | +|---------|-------------|---------------| +| `discover` | Resolve a TEI to product release UUID(s) | `TEI` (positional) | +| `search-products` | Search products by identifier | `--id-type`, `--id-value`, `--page-offset`, `--page-size` | +| `search-releases` | Search product releases by identifier | `--id-type`, `--id-value`, `--page-offset`, `--page-size` | +| `get-product` | Get a product by UUID | `UUID` (positional) | +| `get-release` | Get a product or component release by UUID | `UUID` (positional), `--component` flag | +| `get-collection` | Get a collection | `UUID` (positional), `--version INT`, `--component` flag. Defaults to latest | +| `get-artifact` | Get artifact metadata by UUID | `UUID` (positional) | +| `download` | Download an artifact file | `URL` (positional), `DEST` (positional), `--checksum` (repeatable: `ALG:VALUE`) | +| `inspect` | Full flow: TEI → discovery → releases → artifacts | `TEI` (positional) | + +### Output + +All commands print JSON to stdout. Errors go to stderr. Exit code 0 on success, 1 on error. + +For model objects, serialize via Pydantic's `.model_dump(mode="json", by_alias=True)` to produce camelCase JSON matching the TEA spec. + +### `inspect` command flow + +The `inspect` command implements a full TEA consumer flow (like rearm's `full_tea_flow`): + +1. Discover product release UUIDs from TEI +2. For each product release: fetch release details +3. For each component: fetch component release with latest collection +4. Print structured JSON with all artifacts and download URLs + +### Testing strategy + +- Test each command with mocked HTTP responses +- Test global option validation (mutually exclusive `--base-url`/`--domain`) +- Test env var fallback for `--token` +- Test error output formatting + +--- + +## 7. Regex Removal + +Remove all 3 regex patterns from the codebase. Zero `import re` remaining. + +### 7.1 `_SAFE_PATH_SEGMENT_RE` (client.py) + +**Current:** `re.compile(r"^[a-zA-Z0-9\-]{1,128}$")` + +**Replace with:** + +```python +_SAFE_PATH_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") + +def _validate_path_segment(value: str, name: str = "uuid") -> str: + if not value or len(value) > 128 or not all(c in _SAFE_PATH_CHARS for c in value): + raise TeaValidationError( + f"Invalid {name}: {value!r}. " + "Must contain only alphanumeric characters and hyphens, max 128 characters." + ) + return value +``` + +### 7.2 `_SEMVER_RE` (discovery.py) + +**Current:** `re.compile(r"^(?P\d+)\.(?P\d+)(?:\.(?P\d+))?(?:-(?P
[0-9A-Za-z.-]+))?$")`
+
+**Replace with string splitting:**
+
+```python
+def __init__(self, version_str: str):
+    # Split pre-release: "1.2.3-beta.2" -> "1.2.3", "beta.2"
+    if "-" in version_str:
+        ver_part, pre_part = version_str.split("-", 1)
+    else:
+        ver_part, pre_part = version_str, None
+
+    # Split version: "1.2.3" -> ["1", "2", "3"]
+    parts = ver_part.split(".")
+    if len(parts) < 2 or len(parts) > 3:
+        raise ValueError(...)
+    if not all(p.isdigit() for p in parts):
+        raise ValueError(...)
+
+    self.major = int(parts[0])
+    self.minor = int(parts[1])
+    self.patch = int(parts[2]) if len(parts) == 3 else 0
+    self.pre = tuple(self._parse_pre(pre_part)) if pre_part else ()
+```
+
+### 7.3 `_DOMAIN_RE` (discovery.py)
+
+**Current:** `re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$")`
+
+**Replace with label-by-label validation:**
+
+```python
+_DOMAIN_LABEL_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-")
+
+def _is_valid_domain(domain: str) -> bool:
+    if not domain:
+        return False
+    labels = domain.split(".")
+    for label in labels:
+        if not label or len(label) > 63:
+            return False
+        if label[0] == "-" or label[-1] == "-":
+            return False
+        if not all(c in _DOMAIN_LABEL_CHARS for c in label):
+            return False
+    return True
+```
+
+### Testing strategy
+
+All existing tests pass unchanged — same validation rules, different implementation.
+
+---
+
+## 8. Discovery Enhancements
+
+### `fetch_well_known` gains `scheme` and `port` parameters
+
+Enables parity with rearm's `--usehttp` and `--useport` flags.
+
+```python
+def fetch_well_known(
+    domain: str,
+    *,
+    timeout: float = 10.0,
+    scheme: str = "https",
+    port: int | None = None,
+) -> TeaWellKnown:
+    if scheme not in ("http", "https"):
+        raise TeaDiscoveryError(f"Invalid scheme: {scheme!r}. Must be 'http' or 'https'.")
+
+    default_port = 80 if scheme == "http" else 443
+    resolved_port = port if port is not None else default_port
+
+    if resolved_port == default_port:
+        url = f"{scheme}://{domain}/.well-known/tea"
+    else:
+        url = f"{scheme}://{domain}:{resolved_port}/.well-known/tea"
+    # ... rest of existing logic
+```
+
+### Testing strategy
+
+- Test HTTP scheme constructs `http://` URL
+- Test custom port is included in URL
+- Test default ports (443/80) are omitted from URL
+- Test invalid scheme raises error
+
+---
+
+## 9. Spec Alignment Fixes
+
+### `DiscoveryInfo.servers` min_length
+
+```python
+class DiscoveryInfo(_TeaModel):
+    product_release_uuid: str
+    servers: list[TeaServerInfo] = Field(min_length=1)  # spec: minItems: 1
+```
+
+### `IdentifierType.UDI` disclaimer
+
+```python
+class IdentifierType(StrEnum):
+    CPE = "CPE"
+    TEI = "TEI"
+    PURL = "PURL"
+    UDI = "UDI"  # Not in spec's identifier-type enum; included for forward-compatibility
+```
+
+---
+
+## New dependency summary
+
+| Package | Version | Purpose | Type |
+|---------|---------|---------|------|
+| `semver` | >= 3.0.4, < 4 | SemVer 2.0 version comparison | Runtime |
+| `typer` | >= 0.12.0, < 1 | CLI framework | Optional (`[cli]` extra) |
+
+`urllib3.Retry` and mTLS are built into existing `requests`/`urllib3` stack. `typer` is only installed with `pip install libtea[cli]`.
+
+---
+
+## File changes summary
+
+| File | Changes |
+|------|---------|
+| `libtea/models.py` | Add 6 CLE models, add `min_length=1` to `DiscoveryInfo.servers` |
+| `libtea/client.py` | Add 4 CLE methods, update `__init__` for mtls/basic_auth, update `from_well_known` for failover, remove regex |
+| `libtea/_http.py` | Add `max_retries`/`backoff_factor`, `MtlsConfig`, `basic_auth`, retry adapter |
+| `libtea/discovery.py` | Replace `select_endpoint()` with SemVer-based matching, remove regexes, add `scheme`/`port` params |
+| `libtea/cli.py` | New file: typer CLI with all commands |
+| `pyproject.toml` | Add `semver` dep, add `[cli]` optional extra with typer, bump to 0.2.0 |
+| `tests/test_models.py` | CLE model tests |
+| `tests/test_client.py` | CLE endpoint tests |
+| `tests/test_discovery.py` | SemVer matching tests, scheme/port tests |
+| `tests/test_http.py` | Retry, failover, mTLS, basic auth tests |
+| `tests/test_cli.py` | New file: CLI command tests |
+
+---
+
+## Migration notes
+
+### Breaking changes
+
+- `DiscoveryInfo.servers` now requires at least 1 element (`min_length=1`). Previously accepted empty lists. This matches the spec and is unlikely to affect real-world usage.
+- Regex removal changes internal implementation but preserves identical validation behavior.
+
+### Deprecations: None
+
+---
+
+## References
+
+- TEA consumer OpenAPI spec: `/tmp/transparency-exchange-api/spec/openapi.yaml`
+- TEA discovery spec: `/tmp/transparency-exchange-api/discovery/readme.md`
+- TEA auth spec: `/tmp/transparency-exchange-api/auth/readme.md`
+- Well-known schema: `/tmp/transparency-exchange-api/discovery/tea-well-known.schema.json`
+- CLE PR: [#213](https://github.com/CycloneDX/transparency-exchange-api/pull/213) (merged Feb 20, 2026)
+- ECMA-428 (CLE): [ecma-international.org](https://ecma-international.org/publications-and-standards/standards/ecma-428/)
+- SemVer 2.0: [semver.org](https://semver.org/)
+- python-semver: [PyPI](https://pypi.org/project/semver/)
+- Rearm CLI (reference): `/tmp/rearm-cli/cmd/tea.go`
+- Oolong server (reference): `/tmp/rearm-cli/cmd/oolong.go`
diff --git a/docs/plans/2026-02-26-v0.3.0-design.md b/docs/plans/2026-02-26-v0.3.0-design.md
new file mode 100644
index 0000000..9e92cb6
--- /dev/null
+++ b/docs/plans/2026-02-26-v0.3.0-design.md
@@ -0,0 +1,710 @@
+# py-libtea v0.3.0 Design Document
+
+**Goal:** Migrate from requests to httpx, add `AsyncTeaClient`, introduce pagination auto-iteration, add SemVer range matching, and address deferred code quality findings from the v0.2.0 review.
+
+**Spec version:** TEA v0.3.0-beta.2 (OpenAPI 3.1.1) — [Ecma TC54-TG1](https://tc54.org/tea/) | [GitHub](https://github.com/CycloneDX/transparency-exchange-api)
+
+---
+
+## Scope
+
+### In scope (v0.3.0)
+
+| Feature | Rationale | Effort |
+|---------|-----------|--------|
+| httpx migration (sync + async) | Prerequisite for async client; unlocks `ssl.SSLContext` for encrypted mTLS keys | Large |
+| `AsyncTeaClient` | Enables non-blocking usage in async frameworks (FastAPI, aiohttp, CI pipelines) | Medium |
+| Pagination auto-iteration | Convenience wrapper to auto-page through `search_products`, `search_product_releases`, `get_product_releases` | Small |
+| SemVer range matching in endpoint selection | Current is exact-match only; spec implies "highest matching version" could support compatible ranges | Small |
+| DNS-based TEI resolution | Full DNS TXT record lookup for TEI discovery, in addition to `.well-known` | Medium |
+| Protocol/ABC for client interface | Enables mocking without the concrete class; improves testability for consumers | Small |
+| `download_with_hashes` refactor | Reduce cyclomatic complexity; extract redirect-following and streaming into focused helpers | Small |
+| `_probe_endpoint` via transport layer | Route probes through `_http.py` instead of standalone `requests.head()` | Small |
+| Interactive CLI disambiguation | When multiple endpoints or discovery results match, prompt the user to choose | Small |
+
+### Out of scope (deferred)
+
+| Feature | Reason |
+|---------|--------|
+| Publisher API | Blocked on TEA spec — see `docs/FUTURE.md` |
+| `extra="forbid"` on models | Would be a breaking change for consumers; `extra="ignore"` is the safer default |
+
+---
+
+## 1. httpx Migration
+
+### Why
+
+- `requests` has no native async support — the only path to `AsyncTeaClient`
+- httpx provides `ssl.SSLContext` for encrypted (password-protected) mTLS private keys (blocked in v0.2.0)
+- httpx has built-in HTTP/2 support (opt-in)
+- httpx's transport API enables proper socket-level IP pinning (closes the DNS rebinding TOCTOU gap from v0.2.0)
+- httpx's `follow_redirects` + event hooks replace our manual redirect loop in `download_with_hashes`
+
+### Dependency changes
+
+```toml
+# Remove
+"requests>=2.32.0,<3",
+
+# Add
+"httpx>=0.27.0,<1",
+```
+
+Dev dependencies:
+
+```toml
+# Remove
+"responses>=0.26.0,<1",
+
+# Add
+"respx>=0.22.0,<1",     # httpx mock library
+"pytest-asyncio>=0.24.0,<1",
+```
+
+### Migration strategy
+
+The migration touches `_http.py` (core), `discovery.py` (standalone fetch), and all test files. The approach:
+
+1. Replace `requests.Session` with `httpx.Client` (sync) in `_http.py`
+2. Create `_async_http.py` with `httpx.AsyncClient` (mirrors `_http.py`)
+3. Replace `requests.get()` in `discovery.py` with `httpx.get()`
+4. Replace `responses` mocks with `respx` in all test files
+5. Update `pyproject.toml` dependencies
+
+### Key mapping
+
+| requests | httpx |
+|----------|-------|
+| `requests.Session()` | `httpx.Client()` |
+| `session.get(url, params=...)` | `client.get(url, params=...)` |
+| `response.json()` | `response.json()` |
+| `response.status_code` | `response.status_code` |
+| `response.text` | `response.text` |
+| `response.iter_content(chunk_size=N)` | `response.iter_bytes(chunk_size=N)` |
+| `session.headers[...] = ...` | `client.headers[...] = ...` |
+| `session.cert = (cert, key)` | `httpx.Client(cert=(cert, key))` |
+| `session.verify = ca_bundle` | `httpx.Client(verify=ca_bundle)` |
+| `session.auth = (user, pass)` | `httpx.Client(auth=(user, pass))` |
+| `requests.ConnectionError` | `httpx.ConnectError` |
+| `requests.Timeout` | `httpx.TimeoutException` |
+| `requests.RequestException` | `httpx.HTTPError` |
+| `HTTPAdapter(max_retries=Retry(...))` | `httpx.Client(transport=httpx.HTTPTransport(retries=N))` |
+
+### Retry via httpx
+
+httpx's built-in retry is simpler than urllib3's `Retry`:
+
+```python
+transport = httpx.HTTPTransport(retries=max_retries)
+client = httpx.Client(transport=transport, timeout=timeout)
+```
+
+Note: httpx's transport-level retries only retry on connection failures, not on 5xx status codes. For 5xx retry we need a custom transport or event hook:
+
+```python
+class RetryTransport(httpx.BaseTransport):
+    """Transport that retries on 5xx responses with exponential backoff."""
+
+    def __init__(
+        self,
+        *,
+        max_retries: int = 3,
+        backoff_factor: float = 0.5,
+        status_forcelist: frozenset[int] = frozenset({500, 502, 503, 504}),
+    ):
+        self._wrapped = httpx.HTTPTransport()
+        self._max_retries = max_retries
+        self._backoff_factor = backoff_factor
+        self._status_forcelist = status_forcelist
+
+    def handle_request(self, request: httpx.Request) -> httpx.Response:
+        import time
+
+        last_response = None
+        for attempt in range(self._max_retries + 1):
+            response = self._wrapped.handle_request(request)
+            if response.status_code not in self._status_forcelist:
+                return response
+            last_response = response
+            if attempt < self._max_retries:
+                delay = self._backoff_factor * (2 ** attempt)
+                time.sleep(delay)
+        return last_response
+```
+
+Async variant uses `httpx.AsyncHTTPTransport` and `asyncio.sleep`.
+
+### mTLS with encrypted keys
+
+The v0.2.0 limitation (no encrypted private keys) is resolved:
+
+```python
+import ssl
+
+ssl_context = ssl.create_default_context()
+ssl_context.load_cert_chain(
+    certfile=str(mtls.client_cert),
+    keyfile=str(mtls.client_key),
+    password=mtls.key_password,  # New field
+)
+client = httpx.Client(verify=ssl_context)
+```
+
+Add optional `key_password` to `MtlsConfig`:
+
+```python
+@dataclass(frozen=True)
+class MtlsConfig:
+    client_cert: Path
+    client_key: Path
+    ca_bundle: Path | None = None
+    key_password: str | None = None  # New: for encrypted private keys
+```
+
+### DNS rebinding fix via transport
+
+The TOCTOU gap documented in v0.2.0 can be closed with httpx's transport API. A custom transport can pin the resolved IP and reject internal addresses at the socket level:
+
+```python
+class SsrfSafeTransport(httpx.BaseTransport):
+    """Transport that validates resolved IPs before connecting."""
+
+    def handle_request(self, request: httpx.Request) -> httpx.Response:
+        hostname = request.url.host
+        # Resolve and validate IPs
+        for addr_info in socket.getaddrinfo(hostname, request.url.port):
+            ip = ipaddress.ip_address(addr_info[4][0])
+            if _is_internal_ip(ip):
+                raise TeaValidationError(f"SSRF blocked: {hostname} resolves to {ip}")
+        # Pin resolved IP in the request
+        return self._wrapped.handle_request(request)
+```
+
+This eliminates the TOCTOU gap because the transport controls the actual connection.
+
+### Testing
+
+- Replace all `@responses.activate` with `respx.mock`
+- respx syntax: `respx.get(url).respond(json={...})`
+- Async tests use `@pytest.mark.asyncio` + `respx.mock`
+
+---
+
+## 2. AsyncTeaClient
+
+### Design
+
+Mirror the sync `TeaClient` API with `async`/`await`. Both clients share:
+- Models (Pydantic)
+- Exception hierarchy
+- Validation helpers (`_validate`, `_validate_list`, `_validate_path_segment`)
+- Discovery logic (sync `fetch_well_known` stays; add `async_fetch_well_known`)
+
+### File structure
+
+```
+libtea/
+    _http.py              # Sync httpx client (migrated from requests)
+    _async_http.py        # Async httpx client (new)
+    client.py             # TeaClient (sync, uses _http.py)
+    async_client.py       # AsyncTeaClient (new, uses _async_http.py)
+    discovery.py          # Add async_fetch_well_known
+```
+
+### API surface
+
+```python
+class AsyncTeaClient:
+    def __init__(self, base_url: str, *, token=None, basic_auth=None, timeout=30.0, mtls=None): ...
+
+    @classmethod
+    async def from_well_known(cls, domain: str, *, ...) -> Self: ...
+
+    async def discover(self, tei: str) -> list[DiscoveryInfo]: ...
+    async def get_product(self, uuid: str) -> Product: ...
+    async def get_product_releases(self, uuid: str, *, page_offset=0, page_size=100) -> PaginatedProductReleaseResponse: ...
+    async def search_products(self, id_type: str, id_value: str, *, ...) -> PaginatedProductResponse: ...
+    # ... all other methods mirror TeaClient ...
+
+    # Pagination iterators (see section 3)
+    async def iter_products(self, id_type: str, id_value: str, *, page_size=100) -> AsyncIterator[Product]: ...
+    async def iter_product_releases(self, uuid: str, *, page_size=100) -> AsyncIterator[ProductRelease]: ...
+
+    async def download_artifact(self, url: str, dest: Path, *, verify_checksums=None, max_download_bytes=None) -> Path: ...
+
+    async def close(self) -> None: ...
+    async def __aenter__(self) -> Self: ...
+    async def __aexit__(self, *args) -> None: ...
+```
+
+### Shared code
+
+Extract shared logic into `_shared.py` to avoid duplication between sync and async clients:
+
+```python
+# libtea/_shared.py
+"""Shared validation and utility functions for sync and async clients."""
+
+_SAFE_PATH_CHARS = frozenset(...)
+_MAX_PAGE_SIZE = 10000
+_WEAK_HASH_ALGORITHMS = frozenset({"MD5", "SHA-1"})
+
+def _validate(model_cls, data): ...
+def _validate_list(model_cls, data): ...
+def _validate_path_segment(value, name="uuid"): ...
+def _validate_page_size(page_size): ...
+def _validate_page_offset(page_offset): ...
+def _validate_collection_version(version): ...
+def _verify_checksums(checksums, computed, url, dest): ...
+```
+
+Both `client.py` and `async_client.py` import from `_shared.py`.
+
+### Public exports
+
+Add to `__init__.py`:
+
+```python
+from libtea.async_client import AsyncTeaClient
+
+__all__ = [
+    # ... existing ...
+    "AsyncTeaClient",
+]
+```
+
+### Testing
+
+- `tests/test_async_client.py` — mirrors `test_client.py` with `@pytest.mark.asyncio`
+- `tests/test_async_http.py` — mirrors `test_http.py` with async respx mocks
+- Shared test fixtures in `conftest.py` for both sync and async
+
+---
+
+## 3. Pagination Auto-Iteration
+
+### Problem
+
+Currently, paginated endpoints (`search_products`, `search_product_releases`, `get_product_releases`) return a single page. Consumers must manually loop with `page_offset`.
+
+### Design
+
+Add iterator methods that auto-page through results:
+
+```python
+# Sync
+class TeaClient:
+    def iter_products(
+        self, id_type: str, id_value: str, *, page_size: int = 100
+    ) -> Iterator[Product]:
+        """Iterate over all products matching the identifier, auto-paging."""
+        page_offset = 0
+        while True:
+            page = self.search_products(id_type, id_value, page_offset=page_offset, page_size=page_size)
+            yield from page.results
+            if len(page.results) < page_size:
+                break
+            page_offset += page_size
+
+    def iter_product_releases_by_id(
+        self, id_type: str, id_value: str, *, page_size: int = 100
+    ) -> Iterator[ProductRelease]:
+        """Iterate over all product releases matching the identifier, auto-paging."""
+        page_offset = 0
+        while True:
+            page = self.search_product_releases(id_type, id_value, page_offset=page_offset, page_size=page_size)
+            yield from page.results
+            if len(page.results) < page_size:
+                break
+            page_offset += page_size
+
+    def iter_product_releases(
+        self, uuid: str, *, page_size: int = 100
+    ) -> Iterator[ProductRelease]:
+        """Iterate over all releases for a product, auto-paging."""
+        page_offset = 0
+        while True:
+            page = self.get_product_releases(uuid, page_offset=page_offset, page_size=page_size)
+            yield from page.results
+            if len(page.results) < page_size:
+                break
+            page_offset += page_size
+```
+
+```python
+# Async
+class AsyncTeaClient:
+    async def iter_products(
+        self, id_type: str, id_value: str, *, page_size: int = 100
+    ) -> AsyncIterator[Product]:
+        page_offset = 0
+        while True:
+            page = await self.search_products(id_type, id_value, page_offset=page_offset, page_size=page_size)
+            for item in page.results:
+                yield item
+            if len(page.results) < page_size:
+                break
+            page_offset += page_size
+```
+
+### Stop condition
+
+The TEA spec pagination response includes `totalResults`. We use the simpler heuristic: stop when a page returns fewer results than `page_size`. This avoids relying on `totalResults` being accurate (some servers may not populate it).
+
+### CLI integration
+
+Add `--all` flag to search commands:
+
+```
+tea-cli search-products --id-type PURL --id-value "pkg:pypi/requests" --all
+```
+
+When `--all` is set, use the iterator and stream results as NDJSON (one JSON object per line) to avoid buffering the entire result set.
+
+### Testing
+
+- Mock multi-page responses (3 pages of data)
+- Verify iteration stops on last page
+- Verify empty result set yields nothing
+- Verify async iteration matches sync behavior
+
+---
+
+## 4. SemVer Range Matching
+
+### Current behavior
+
+`select_endpoints()` uses exact SemVer equality: the client asks for `0.3.0-beta.2`, the server must advertise exactly that string.
+
+### Proposed behavior
+
+Support compatible version ranges. A client requesting `0.3.0` should match any `0.3.x` endpoint (per SemVer compatibility). Pre-release versions remain exact-match only (per SemVer 2.0.0 spec: pre-releases have lower precedence and are not interchangeable).
+
+```python
+def _is_compatible(target: _SemVer, candidate: _SemVer) -> bool:
+    """Check if candidate is compatible with target per SemVer.
+
+    Rules:
+    - Pre-release targets require exact match (0.3.0-beta.2 != 0.3.0-beta.3)
+    - Release targets match any candidate with same major.minor (0.3.0 matches 0.3.1)
+    - Major version 0 is special: 0.x.y only matches 0.x.z (not 0.y.z)
+    """
+    if target.prerelease:
+        return candidate == target
+    if target.major == 0:
+        return candidate.major == 0 and candidate.minor == target.minor and not candidate.prerelease
+    return candidate.major == target.major and candidate.minor >= target.minor and not candidate.prerelease
+```
+
+### Backward compatibility
+
+This is additive — exact matches still work. Consumers who pass `0.3.0-beta.2` get the same behavior as v0.2.0. Only release version requests (`1.0.0`) gain range matching.
+
+### Testing
+
+- `0.3.0` matches `0.3.0`, `0.3.1`, `0.3.99` but NOT `0.4.0`
+- `0.3.0-beta.2` matches ONLY `0.3.0-beta.2` (exact)
+- `1.0.0` matches `1.0.0`, `1.1.0`, `1.99.0` but NOT `2.0.0`
+- Among multiple matches, highest version + highest priority wins
+
+---
+
+## 5. DNS-Based TEI Resolution
+
+### Current behavior
+
+TEI resolution uses only the `.well-known/tea` HTTP endpoint. The TEA spec also defines a DNS TXT record mechanism.
+
+### Spec requirement
+
+From `discovery/readme.md`:
+
+> A DNS TXT record at `_tea.` MAY contain a JSON pointer to the TEA endpoint(s).
+
+### Implementation
+
+Add `resolve_tei_dns()` to `discovery.py`:
+
+```python
+import dns.resolver  # dnspython
+
+def resolve_tei_dns(domain: str) -> TeaWellKnown | None:
+    """Attempt DNS TXT record resolution for TEA discovery.
+
+    Queries _tea. for TXT records containing a JSON well-known document.
+
+    Returns:
+        Parsed TeaWellKnown if found, None if no TXT record exists.
+
+    Raises:
+        TeaDiscoveryError: If TXT record exists but contains invalid data.
+    """
+    try:
+        answers = dns.resolver.resolve(f"_tea.{domain}", "TXT")
+    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
+        return None
+
+    for rdata in answers:
+        txt = b"".join(rdata.strings).decode("utf-8")
+        try:
+            data = json.loads(txt)
+            return TeaWellKnown.model_validate(data)
+        except (json.JSONDecodeError, ValidationError) as exc:
+            raise TeaDiscoveryError(f"Invalid TEA DNS TXT record at _tea.{domain}: {exc}") from exc
+
+    return None
+```
+
+### New optional dependency
+
+```toml
+[project.optional-dependencies]
+dns = ["dnspython>=2.6.0,<3"]
+```
+
+The DNS resolution is opt-in. If `dnspython` is not installed, `resolve_tei_dns()` raises an `ImportError` with a clear message.
+
+### Discovery flow update
+
+Update `TeaClient.from_well_known()` to try DNS first, then fall back to HTTP:
+
+```python
+@classmethod
+def from_well_known(cls, domain, *, prefer_dns=False, **kwargs):
+    well_known = None
+    if prefer_dns:
+        try:
+            well_known = resolve_tei_dns(domain)
+        except ImportError:
+            logger.info("dnspython not installed, skipping DNS resolution")
+        except TeaDiscoveryError:
+            logger.info("DNS resolution failed, falling back to HTTP")
+
+    if well_known is None:
+        well_known = fetch_well_known(domain, **kwargs)
+
+    # ... rest of endpoint selection and failover ...
+```
+
+### Testing
+
+- Mock DNS responses with `dnspython`'s test utilities
+- Test fallback when no TXT record exists
+- Test invalid TXT record raises `TeaDiscoveryError`
+- Test `prefer_dns=True` tries DNS first
+- Test missing `dnspython` is handled gracefully
+
+---
+
+## 6. Protocol/ABC for Client Interface
+
+### Problem
+
+Consumers who want to mock `TeaClient` in their tests must either mock the concrete class or use `unittest.mock.MagicMock`. A protocol enables type-safe mocking.
+
+### Implementation
+
+Add `libtea/protocols.py`:
+
+```python
+from typing import Protocol, Iterator, runtime_checkable
+from pathlib import Path
+from libtea.models import (
+    Product, ProductRelease, Component, Release, Collection,
+    ComponentReleaseWithCollection, Artifact, DiscoveryInfo,
+    PaginatedProductResponse, PaginatedProductReleaseResponse,
+    CLE, Checksum,
+)
+
+@runtime_checkable
+class TeaClientProtocol(Protocol):
+    """Protocol for TEA consumer clients (sync)."""
+
+    def discover(self, tei: str) -> list[DiscoveryInfo]: ...
+    def search_products(self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductResponse: ...
+    def search_product_releases(self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductReleaseResponse: ...
+    def get_product(self, uuid: str) -> Product: ...
+    def get_product_releases(self, uuid: str, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductReleaseResponse: ...
+    def get_product_release(self, uuid: str) -> ProductRelease: ...
+    def get_product_release_collection_latest(self, uuid: str) -> Collection: ...
+    def get_product_release_collections(self, uuid: str) -> list[Collection]: ...
+    def get_product_release_collection(self, uuid: str, version: int) -> Collection: ...
+    def get_component(self, uuid: str) -> Component: ...
+    def get_component_releases(self, uuid: str) -> list[Release]: ...
+    def get_component_release(self, uuid: str) -> ComponentReleaseWithCollection: ...
+    def get_component_release_collection_latest(self, uuid: str) -> Collection: ...
+    def get_component_release_collections(self, uuid: str) -> list[Collection]: ...
+    def get_component_release_collection(self, uuid: str, version: int) -> Collection: ...
+    def get_product_cle(self, uuid: str) -> CLE: ...
+    def get_product_release_cle(self, uuid: str) -> CLE: ...
+    def get_component_cle(self, uuid: str) -> CLE: ...
+    def get_component_release_cle(self, uuid: str) -> CLE: ...
+    def get_artifact(self, uuid: str) -> Artifact: ...
+    def download_artifact(self, url: str, dest: Path, *, verify_checksums: list[Checksum] | None = None, max_download_bytes: int | None = None) -> Path: ...
+    def close(self) -> None: ...
+    def __enter__(self) -> "TeaClientProtocol": ...
+    def __exit__(self, *args) -> None: ...
+```
+
+`AsyncTeaClientProtocol` mirrors with `async` methods and `__aenter__`/`__aexit__`.
+
+### Export
+
+```python
+# __init__.py
+from libtea.protocols import TeaClientProtocol, AsyncTeaClientProtocol
+```
+
+### Testing
+
+- Verify `isinstance(TeaClient(...), TeaClientProtocol)` is `True`
+- Verify `isinstance(AsyncTeaClient(...), AsyncTeaClientProtocol)` is `True`
+- Verify a simple mock implementing the protocol passes `isinstance` check
+
+---
+
+## 7. Code Quality Refactors
+
+### 7.1 `download_with_hashes` decomposition
+
+Extract the redirect-following loop and streaming logic into focused helpers:
+
+```python
+def _follow_redirects(session, url, *, timeout, max_redirects=10):
+    """Follow redirects with SSRF validation at each hop. Returns final response."""
+    ...
+
+def _stream_to_file(response, dest, hashers, *, max_bytes=None):
+    """Stream response body to file, updating hashers. Returns byte count."""
+    ...
+
+def download_with_hashes(self, url, dest, algorithms=None, *, max_download_bytes=None):
+    """Download a file and compute checksums on-the-fly."""
+    _validate_download_url(url)
+    hashers = _build_hashers(algorithms) if algorithms else {}
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    try:
+        with httpx.Client() as download_client:
+            response = _follow_redirects(download_client, url, timeout=self._timeout)
+            _stream_to_file(response, dest, hashers, max_bytes=max_download_bytes)
+    except ...:
+        dest.unlink(missing_ok=True)
+        raise
+    return {alg: h.hexdigest() for alg, h in hashers.items()}
+```
+
+### 7.2 `_probe_endpoint` via transport
+
+Currently `_probe_endpoint` uses a standalone `requests.head()`. After httpx migration, route it through the same transport layer:
+
+```python
+def _probe_endpoint(url: str, timeout: float = 5.0, mtls: MtlsConfig | None = None) -> None:
+    kwargs = _build_client_kwargs(mtls=mtls, timeout=timeout)
+    with httpx.Client(**kwargs) as client:
+        response = client.head(url)
+        if response.status_code >= 500:
+            raise TeaServerError(f"Server error: HTTP {response.status_code}")
+```
+
+This ensures SSRF protection, retry, and mTLS apply to probes.
+
+---
+
+## 8. Interactive CLI Disambiguation
+
+### Problem
+
+When `tea-cli inspect` discovers multiple product releases, or when `from_well_known` finds multiple endpoints, the current behavior silently uses the first result. For CLI users, this may not be the right choice.
+
+### Implementation
+
+Add `--interactive` / `-i` flag to relevant commands:
+
+```python
+@app.command()
+def inspect(
+    tei: str,
+    interactive: Annotated[bool, typer.Option("--interactive", "-i", help="Prompt to choose when multiple results")] = False,
+    ...
+):
+    discoveries = client.discover(tei)
+    if interactive and len(discoveries) > 1:
+        # Display numbered list and prompt
+        for i, d in enumerate(discoveries):
+            print(f"  [{i+1}] {d.product_release_uuid}", file=sys.stderr)
+        choice = typer.prompt("Select product release", type=int, default=1)
+        discoveries = [discoveries[choice - 1]]
+    ...
+```
+
+Non-interactive (default) behavior is unchanged — processes all results.
+
+---
+
+## Dependency summary
+
+| Package | Version | Purpose | Type | Change |
+|---------|---------|---------|------|--------|
+| `httpx` | >= 0.27.0, < 1 | HTTP client (sync + async) | Runtime | New |
+| `pydantic` | >= 2.1.0, < 3 | Data models | Runtime | Unchanged |
+| `semver` | >= 3.0.4, < 4 | SemVer comparison | Runtime | Unchanged |
+| `dnspython` | >= 2.6.0, < 3 | DNS TXT resolution | Optional (`[dns]`) | New |
+| `typer` | >= 0.12.0, < 1 | CLI framework | Optional (`[cli]`) | Unchanged |
+| `requests` | — | — | — | **Removed** |
+| `respx` | >= 0.22.0, < 1 | httpx mocking | Dev | New (replaces `responses`) |
+| `pytest-asyncio` | >= 0.24.0, < 1 | Async test support | Dev | New |
+| `responses` | — | — | — | **Removed** |
+
+---
+
+## File changes summary
+
+| File | Changes |
+|------|---------|
+| `libtea/_http.py` | Migrate from `requests.Session` to `httpx.Client`, add `RetryTransport`, `SsrfSafeTransport`, encrypted mTLS |
+| `libtea/_async_http.py` | **New**: async mirror of `_http.py` using `httpx.AsyncClient` |
+| `libtea/_shared.py` | **New**: shared validation functions extracted from `client.py` |
+| `libtea/client.py` | Import from `_shared.py`, add `iter_*` pagination methods |
+| `libtea/async_client.py` | **New**: `AsyncTeaClient` with full async API + async iterators |
+| `libtea/protocols.py` | **New**: `TeaClientProtocol`, `AsyncTeaClientProtocol` |
+| `libtea/discovery.py` | Migrate to httpx, add `async_fetch_well_known`, add `resolve_tei_dns` |
+| `libtea/cli.py` | Add `--interactive`, `--all` for search, NDJSON streaming |
+| `libtea/__init__.py` | Export `AsyncTeaClient`, protocols, `resolve_tei_dns` |
+| `pyproject.toml` | Swap requests→httpx, add `[dns]` extra, add dev deps, bump to 0.3.0 |
+| `tests/test_http.py` | Migrate from `responses` to `respx` |
+| `tests/test_async_http.py` | **New**: async transport tests |
+| `tests/test_client.py` | Migrate mocks, add pagination iterator tests |
+| `tests/test_async_client.py` | **New**: full async client test suite |
+| `tests/test_discovery.py` | Migrate mocks, add DNS resolution tests |
+| `tests/test_protocols.py` | **New**: protocol isinstance checks |
+| `tests/test_cli.py` | Add interactive and `--all` tests |
+
+---
+
+## Migration notes
+
+### Breaking changes
+
+- **`requests` removed** — consumers who imported `requests.Session` from `_http.py` internals will break. Public API (`TeaClient`, `MtlsConfig`) is unchanged.
+- **`MtlsConfig` gains `key_password` field** — optional, backward compatible (defaults to `None`).
+- **Exception wrapping** — `TeaConnectionError` now wraps `httpx.ConnectError` instead of `requests.ConnectionError`. Consumers catching `TeaConnectionError` (not the underlying exception) are unaffected.
+
+### Deprecations: None
+
+### New extras
+
+- `pip install libtea[dns]` — enables DNS-based TEI resolution
+- `pip install libtea[async]` — not needed (httpx is now a core dependency)
+
+---
+
+## References
+
+- TEA spec: `/tmp/transparency-exchange-api/`
+- httpx docs: [encode/httpx](https://www.python-httpx.org/)
+- respx docs: [lundberg/respx](https://lundberg.github.io/respx/)
+- dnspython: [rthalley/dnspython](https://dnspython.readthedocs.io/)
+- SemVer 2.0.0: [semver.org](https://semver.org/)
+- v0.2.0 design: `docs/plans/2026-02-25-v0.2.0-design.md`
+- v0.2.0 review findings: P3-3 (_probe_endpoint), P3-4 (download complexity), P3-5 (Protocol/ABC)
diff --git a/libtea/_http.py b/libtea/_http.py
deleted file mode 100644
index 8d94aef..0000000
--- a/libtea/_http.py
+++ /dev/null
@@ -1,257 +0,0 @@
-"""Internal HTTP client wrapping requests with TEA error handling."""
-
-import hashlib
-import logging
-import warnings
-from pathlib import Path
-from types import TracebackType
-from typing import Any, Self
-from urllib.parse import urlparse
-
-import requests
-
-from libtea.exceptions import (
-    TeaAuthenticationError,
-    TeaChecksumError,
-    TeaConnectionError,
-    TeaInsecureTransportWarning,
-    TeaNotFoundError,
-    TeaRequestError,
-    TeaServerError,
-    TeaValidationError,
-)
-
-logger = logging.getLogger("libtea")
-
-# Hash algorithm registry: {TEA name: (hashlib name, digest_size)}.
-# When digest_size is None, hashlib.new(name) is used with its default size.
-# When digest_size is set, hashlib.blake2b(digest_size=N) is used instead.
-# BLAKE3 is intentionally excluded — handled separately in _build_hashers.
-_HASH_REGISTRY: dict[str, tuple[str, int | None]] = {
-    "MD5": ("md5", None),
-    "SHA-1": ("sha1", None),
-    "SHA-256": ("sha256", None),
-    "SHA-384": ("sha384", None),
-    "SHA-512": ("sha512", None),
-    "SHA3-256": ("sha3_256", None),
-    "SHA3-384": ("sha3_384", None),
-    "SHA3-512": ("sha3_512", None),
-    "BLAKE2b-256": ("blake2b", 32),
-    "BLAKE2b-384": ("blake2b", 48),
-    "BLAKE2b-512": ("blake2b", 64),
-}
-
-
-def _get_package_version() -> str:
-    """Get the package version for User-Agent header."""
-    try:
-        from importlib.metadata import PackageNotFoundError, version
-
-        return version("libtea")
-    except (PackageNotFoundError, ValueError):
-        return "unknown"
-
-
-USER_AGENT = f"py-libtea/{_get_package_version()} (hello@sbomify.com)"
-
-_BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "data"})
-
-
-def _build_hashers(algorithms: list[str]) -> dict[str, Any]:
-    """Build hashlib hasher objects for the given algorithm names."""
-    hashers: dict[str, Any] = {}
-    for alg in algorithms:
-        if alg == "BLAKE3":
-            raise TeaChecksumError(
-                "BLAKE3 is not supported by Python's hashlib. "
-                "Install the 'blake3' package or use a different algorithm.",
-                algorithm="BLAKE3",
-            )
-        entry = _HASH_REGISTRY.get(alg)
-        if entry is None:
-            raise TeaChecksumError(
-                f"Unsupported checksum algorithm: {alg!r}. Supported: {', '.join(sorted(_HASH_REGISTRY.keys()))}",
-                algorithm=alg,
-            )
-        hashlib_name, digest_size = entry
-        if digest_size is not None:
-            hashers[alg] = hashlib.blake2b(digest_size=digest_size)
-        else:
-            hashers[alg] = hashlib.new(hashlib_name)
-    return hashers
-
-
-def _validate_download_url(url: str) -> None:
-    """Reject download URLs that use non-HTTP schemes."""
-    parsed = urlparse(url)
-    if parsed.scheme in _BLOCKED_SCHEMES or parsed.scheme not in ("http", "https"):
-        raise TeaValidationError(f"Artifact download URL must use http or https scheme, got {parsed.scheme!r}")
-    if not parsed.hostname:
-        raise TeaValidationError(f"Artifact download URL must include a hostname: {url!r}")
-
-
-class TeaHttpClient:
-    """Low-level HTTP client for TEA API requests.
-
-    Handles authentication headers, error mapping, and streaming downloads.
-    Uses a separate unauthenticated session for artifact downloads to avoid
-    leaking bearer tokens to third-party hosts.
-
-    Args:
-        base_url: TEA server base URL.
-        token: Optional bearer token. Rejected with plaintext HTTP.
-        timeout: Request timeout in seconds.
-    """
-
-    def __init__(
-        self,
-        base_url: str,
-        *,
-        token: str | None = None,
-        timeout: float = 30.0,
-    ):
-        parsed = urlparse(base_url)
-        if parsed.scheme not in ("http", "https"):
-            raise ValueError(f"base_url must use http or https scheme, got {parsed.scheme!r}")
-        if not parsed.hostname:
-            raise ValueError(f"base_url must include a hostname: {base_url!r}")
-        if parsed.scheme == "http" and token:
-            raise ValueError("Cannot use bearer token with plaintext HTTP. Use https:// or remove the token.")
-        if parsed.scheme == "http":
-            warnings.warn(
-                "Using plaintext HTTP is insecure. Use HTTPS in production.",
-                TeaInsecureTransportWarning,
-                stacklevel=2,
-            )
-        self._base_url = parsed.geturl().rstrip("/")
-        self._timeout = timeout
-        self._session = requests.Session()
-        self._session.headers["user-agent"] = USER_AGENT
-        if token:
-            self._session.headers["authorization"] = f"Bearer {token}"
-
-    def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
-        """Send GET request and return parsed JSON.
-
-        Args:
-            path: URL path relative to base URL (e.g. ``/product/{uuid}``).
-            params: Optional query parameters.
-
-        Returns:
-            Parsed JSON response body.
-
-        Raises:
-            TeaConnectionError: On network failure.
-            TeaNotFoundError: On HTTP 404.
-            TeaAuthenticationError: On HTTP 401/403.
-            TeaServerError: On HTTP 5xx.
-        """
-        url = f"{self._base_url}{path}"
-        try:
-            response = self._session.get(url, params=params, timeout=self._timeout, allow_redirects=False)
-        except requests.ConnectionError as exc:
-            logger.warning("Connection error for %s: %s", url, exc)
-            raise TeaConnectionError(str(exc)) from exc
-        except requests.Timeout as exc:
-            logger.warning("Timeout for %s: %s", url, exc)
-            raise TeaConnectionError(str(exc)) from exc
-        except requests.RequestException as exc:
-            logger.warning("Request error for %s: %s", url, exc)
-            raise TeaConnectionError(str(exc)) from exc
-
-        self._raise_for_status(response)
-        try:
-            return response.json()
-        except ValueError as exc:
-            raise TeaValidationError(f"Invalid JSON in response: {exc}") from exc
-
-    def download_with_hashes(self, url: str, dest: Path, algorithms: list[str] | None = None) -> dict[str, str]:
-        """Download a file and compute checksums on-the-fly.
-
-        Uses a separate unauthenticated session so that the bearer token
-        is not leaked to third-party artifact hosts (CDNs, Maven Central, etc.).
-
-        Args:
-            url: Direct download URL.
-            dest: Local file path to write to. Parent directories are created.
-            algorithms: Optional list of checksum algorithm names to compute.
-
-        Returns:
-            Dict mapping algorithm name to hex digest string.
-
-        Raises:
-            TeaConnectionError: On network failure. Partial files are deleted.
-            TeaChecksumError: If an unsupported algorithm is requested.
-        """
-        _validate_download_url(url)
-        hashers = _build_hashers(algorithms) if algorithms else {}
-
-        dest.parent.mkdir(parents=True, exist_ok=True)
-        try:
-            with requests.Session() as download_session:
-                download_session.headers["user-agent"] = USER_AGENT
-                response = download_session.get(url, stream=True, timeout=self._timeout)
-                self._raise_for_status(response)
-                with open(dest, "wb") as f:
-                    for chunk in response.iter_content(chunk_size=8192):
-                        f.write(chunk)
-                        for h in hashers.values():
-                            h.update(chunk)
-        except (requests.ConnectionError, requests.Timeout) as exc:
-            dest.unlink(missing_ok=True)
-            raise TeaConnectionError(str(exc)) from exc
-        except requests.RequestException as exc:
-            dest.unlink(missing_ok=True)
-            raise TeaConnectionError(f"Download failed: {exc}") from exc
-        except Exception:
-            try:
-                dest.unlink(missing_ok=True)
-            except OSError:
-                logger.warning("Failed to clean up partial download at %s", dest)
-            raise
-
-        return {alg: h.hexdigest() for alg, h in hashers.items()}
-
-    def close(self) -> None:
-        self._session.headers.pop("authorization", None)
-        self._session.close()
-
-    def __enter__(self) -> Self:
-        return self
-
-    def __exit__(
-        self,
-        exc_type: type[BaseException] | None,
-        exc_val: BaseException | None,
-        exc_tb: TracebackType | None,
-    ) -> None:
-        self.close()
-
-    @staticmethod
-    def _raise_for_status(response: requests.Response) -> None:
-        """Map HTTP status codes to typed exceptions."""
-        status = response.status_code
-        if 200 <= status < 300:
-            return
-        if 300 <= status < 400:
-            raise TeaRequestError(f"Unexpected redirect: HTTP {status}")
-        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:
-                body = response.json()
-                if isinstance(body, dict):
-                    error_type = body.get("error")
-            except ValueError:
-                pass
-            raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type)
-        if status >= 500:
-            raise TeaServerError(f"Server error: HTTP {status}")
-        # Remaining 4xx codes (400, 405-499 excluding 401/403/404)
-        body_text = response.text[:200] if response.text else ""
-        msg = f"Client error: HTTP {status}"
-        if body_text:
-            msg = f"{msg} — {body_text}"
-        raise TeaRequestError(msg)
diff --git a/libtea/discovery.py b/libtea/discovery.py
deleted file mode 100644
index 502d23c..0000000
--- a/libtea/discovery.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""TEI parsing, .well-known/tea fetching, and endpoint selection."""
-
-import logging
-import re
-from functools import total_ordering
-
-import requests
-from pydantic import ValidationError
-
-from libtea._http import USER_AGENT
-from libtea.exceptions import TeaDiscoveryError
-from libtea.models import TeaEndpoint, TeaWellKnown, TeiType
-
-_SEMVER_RE = re.compile(r"^(?P\d+)\.(?P\d+)(?:\.(?P\d+))?(?:-(?P
[0-9A-Za-z.-]+))?$")
-
-
-@total_ordering
-class _SemVer:
-    """Minimal SemVer 2.0.0 parser for version precedence comparison.
-
-    Implements comparison per https://semver.org/#spec-item-11:
-    - MAJOR.MINOR.PATCH compared numerically left-to-right
-    - Pre-release versions have lower precedence than the normal version
-    - Pre-release identifiers: numeric < alphanumeric, numeric compared as ints,
-      alphanumeric compared lexically; shorter tuple has lower precedence
-    """
-
-    __slots__ = ("major", "minor", "patch", "pre", "_raw")
-
-    def __init__(self, version_str: str) -> None:
-        m = _SEMVER_RE.match(version_str)
-        if not m:
-            raise ValueError(f"Invalid SemVer string: {version_str!r}")
-        self._raw = version_str
-        self.major = int(m["major"])
-        self.minor = int(m["minor"])
-        self.patch = int(m["patch"]) if m["patch"] is not None else 0
-        self.pre: tuple[int | str, ...] = tuple(_SemVer._parse_pre(m["pre"])) if m["pre"] else ()
-
-    @staticmethod
-    def _parse_pre(pre_str: str) -> list[int | str]:
-        parts: list[int | str] = []
-        for part in pre_str.split("."):
-            parts.append(int(part) if part.isdigit() else part)
-        return parts
-
-    def __eq__(self, other: object) -> bool:
-        if not isinstance(other, _SemVer):
-            return NotImplemented
-        return (self.major, self.minor, self.patch, self.pre) == (other.major, other.minor, other.patch, other.pre)
-
-    def __hash__(self) -> int:
-        return hash((self.major, self.minor, self.patch, self.pre))
-
-    def __lt__(self, other: object) -> bool:
-        if not isinstance(other, _SemVer):
-            return NotImplemented
-        if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
-            return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
-        # Pre-release has lower precedence than no pre-release
-        if self.pre and not other.pre:
-            return True
-        if not self.pre and other.pre:
-            return False
-        if not self.pre and not other.pre:
-            return False
-        # Compare pre-release identifiers per SemVer spec item 11.4
-        return _SemVer._compare_pre(self.pre, other.pre) < 0
-
-    @staticmethod
-    def _compare_pre(a: tuple[int | str, ...], b: tuple[int | str, ...]) -> int:
-        for ai, bi in zip(a, b):
-            if type(ai) is type(bi):
-                if ai < bi:  # type: ignore[operator]
-                    return -1
-                if ai > bi:  # type: ignore[operator]
-                    return 1
-            else:
-                # Numeric identifiers always have lower precedence than alphanumeric
-                return -1 if isinstance(ai, int) else 1
-        # Shorter set has lower precedence
-        if len(a) < len(b):
-            return -1
-        if len(a) > len(b):
-            return 1
-        return 0
-
-    def __repr__(self) -> str:
-        return f"_SemVer({self._raw!r})"
-
-    def __str__(self) -> str:
-        return self._raw
-
-
-logger = logging.getLogger("libtea")
-
-_VALID_TEI_TYPES = frozenset(e.value for e in TeiType)
-_DOMAIN_RE = re.compile(
-    r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
-)
-
-
-def parse_tei(tei: str) -> tuple[str, str, str]:
-    """Parse a TEI URN into (type, domain, identifier).
-
-    TEI format: ``urn:tei:::``
-
-    Args:
-        tei: TEI URN string.
-
-    Returns:
-        Tuple of (type, domain, identifier).
-
-    Raises:
-        TeaDiscoveryError: If the TEI format is invalid.
-    """
-    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:::")
-
-    tei_type = parts[2]
-    if tei_type not in _VALID_TEI_TYPES:
-        raise TeaDiscoveryError(
-            f"Invalid TEI type: {tei_type!r}. Must be one of: {', '.join(sorted(_VALID_TEI_TYPES))}"
-        )
-    domain = parts[3]
-    if not domain or not _DOMAIN_RE.match(domain):
-        raise TeaDiscoveryError(f"Invalid domain in TEI: {domain!r}")
-    identifier = ":".join(parts[4:])
-    return tei_type, domain, identifier
-
-
-def fetch_well_known(domain: str, *, timeout: float = 10.0) -> TeaWellKnown:
-    """Fetch and parse the .well-known/tea discovery document from a domain.
-
-    Args:
-        domain: Domain name to resolve (e.g. ``tea.example.com``).
-        timeout: HTTP request timeout in seconds.
-
-    Returns:
-        Parsed well-known document with endpoint list.
-
-    Raises:
-        TeaDiscoveryError: If the domain is invalid, unreachable, or returns
-            an invalid document.
-    """
-    if not domain or not _DOMAIN_RE.match(domain):
-        raise TeaDiscoveryError(f"Invalid domain: {domain!r}")
-    url = f"https://{domain}/.well-known/tea"
-    try:
-        response = requests.get(url, timeout=timeout, allow_redirects=True, headers={"user-agent": USER_AGENT})
-        if 300 <= response.status_code < 400:
-            raise TeaDiscoveryError(f"Unexpected redirect from {url}: HTTP {response.status_code}")
-        if response.status_code >= 400:
-            body_snippet = response.text[:200] if response.text else ""
-            msg = f"Failed to fetch {url}: HTTP {response.status_code}"
-            if body_snippet:
-                msg = f"{msg} — {body_snippet}"
-            raise TeaDiscoveryError(msg)
-    except requests.ConnectionError as exc:
-        logger.warning("Discovery connection error for %s: %s", url, exc)
-        raise TeaDiscoveryError(f"Failed to connect to {url}: {exc}") from exc
-    except requests.Timeout as exc:
-        logger.warning("Discovery timeout for %s: %s", url, exc)
-        raise TeaDiscoveryError(f"Failed to connect to {url}: {exc}") from exc
-    except requests.RequestException as exc:
-        raise TeaDiscoveryError(f"HTTP error fetching {url}: {exc}") from exc
-
-    try:
-        data = response.json()
-    except ValueError as exc:
-        raise TeaDiscoveryError(f"Invalid JSON in .well-known/tea response from {domain}") from exc
-
-    try:
-        return TeaWellKnown.model_validate(data)
-    except ValidationError as exc:
-        raise TeaDiscoveryError(f"Invalid .well-known/tea document from {domain}: {exc}") from exc
-
-
-def select_endpoint(well_known: TeaWellKnown, supported_version: str) -> TeaEndpoint:
-    """Select the best endpoint that supports the given version.
-
-    Per TEA spec: uses SemVer 2.0.0 comparison to match versions, then
-    prioritizes by highest matching version, with priority as tiebreaker.
-
-    Args:
-        well_known: Parsed .well-known/tea document.
-        supported_version: SemVer version string the client supports.
-
-    Returns:
-        The best matching endpoint.
-
-    Raises:
-        TeaDiscoveryError: If no endpoint supports the requested version.
-    """
-    target = _SemVer(supported_version)
-
-    # For each endpoint, find the highest version matching the target via SemVer equality.
-    # This handles cases like "1.0" matching "1.0.0" (patch defaults to 0).
-    candidates: list[tuple[_SemVer, TeaEndpoint]] = []
-    for ep in well_known.endpoints:
-        best_match: _SemVer | None = None
-        for v_str in ep.versions:
-            try:
-                v = _SemVer(v_str)
-            except ValueError:
-                continue
-            if v == target and (best_match is None or v > best_match):
-                best_match = v
-        if best_match is not None:
-            candidates.append((best_match, ep))
-
-    if not candidates:
-        available = {v for ep in well_known.endpoints for v in ep.versions}
-        raise TeaDiscoveryError(
-            f"No compatible endpoint found for version {supported_version!r}. Available versions: {sorted(available)}"
-        )
-
-    # Sort by: highest SemVer version desc, then priority desc (default 1.0 per spec)
-    candidates.sort(
-        key=lambda pair: (pair[0], pair[1].priority if pair[1].priority is not None else 1.0),
-        reverse=True,
-    )
-    return candidates[0][1]
diff --git a/libtea/exceptions.py b/libtea/exceptions.py
deleted file mode 100644
index 626fd3c..0000000
--- a/libtea/exceptions.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Exception hierarchy for the TEA client library."""
-
-
-class TeaError(Exception):
-    """Base exception for all TEA client errors."""
-
-
-class TeaConnectionError(TeaError):
-    """Network or connection failure."""
-
-
-class TeaAuthenticationError(TeaError):
-    """HTTP 401 or 403 response."""
-
-
-class TeaNotFoundError(TeaError):
-    """HTTP 404 response."""
-
-    def __init__(self, message: str, *, error_type: str | None = None):
-        super().__init__(message)
-        self.error_type = error_type
-
-
-class TeaRequestError(TeaError):
-    """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404)."""
-
-
-class TeaServerError(TeaError):
-    """HTTP 5xx response."""
-
-
-class TeaDiscoveryError(TeaError):
-    """Discovery-specific failure (bad TEI, no .well-known, no compatible endpoint)."""
-
-
-class TeaChecksumError(TeaError):
-    """Checksum verification failure on artifact download."""
-
-    def __init__(
-        self,
-        message: str,
-        *,
-        algorithm: str | None = None,
-        expected: str | None = None,
-        actual: str | None = None,
-    ):
-        super().__init__(message)
-        self.algorithm = algorithm
-        self.expected = expected
-        self.actual = actual
-
-
-class TeaValidationError(TeaError):
-    """Malformed server response that fails Pydantic validation."""
-
-
-class TeaInsecureTransportWarning(UserWarning):
-    """Warning emitted when using plaintext HTTP instead of HTTPS."""
diff --git a/libtea/models.py b/libtea/models.py
deleted file mode 100644
index fe661b5..0000000
--- a/libtea/models.py
+++ /dev/null
@@ -1,330 +0,0 @@
-"""Pydantic data models for TEA API objects."""
-
-from datetime import datetime
-from enum import StrEnum
-from typing import Literal
-
-from pydantic import BaseModel, ConfigDict, Field, field_validator
-from pydantic.alias_generators import to_camel
-
-
-class _TeaModel(BaseModel):
-    """Base model with camelCase alias support."""
-
-    model_config = ConfigDict(
-        alias_generator=to_camel,
-        populate_by_name=True,
-        extra="ignore",
-        frozen=True,
-    )
-
-
-# --- Enums ---
-
-
-class IdentifierType(StrEnum):
-    """Identifier type used in product and component identifiers."""
-
-    CPE = "CPE"
-    TEI = "TEI"
-    PURL = "PURL"
-    UDI = "UDI"  # Not in spec's identifier-type enum; included for forward-compatibility
-
-
-class TeiType(StrEnum):
-    """TEI URN scheme types per TEA discovery specification.
-
-    These are the valid ```` values in a TEI URN
-    (``urn:tei:::``).
-    """
-
-    UUID = "uuid"
-    PURL = "purl"
-    HASH = "hash"
-    SWID = "swid"
-    EANUPC = "eanupc"
-    GTIN = "gtin"
-    ASIN = "asin"
-    UDI = "udi"
-
-
-class ChecksumAlgorithm(StrEnum):
-    """Checksum algorithm identifiers per TEA spec.
-
-    Values use hyphen form (e.g. ``SHA-256``). The Checksum model's validator
-    normalizes underscore form (``SHA_256``) to hyphen form automatically.
-    """
-
-    MD5 = "MD5"
-    SHA_1 = "SHA-1"
-    SHA_256 = "SHA-256"
-    SHA_384 = "SHA-384"
-    SHA_512 = "SHA-512"
-    SHA3_256 = "SHA3-256"
-    SHA3_384 = "SHA3-384"
-    SHA3_512 = "SHA3-512"
-    BLAKE2B_256 = "BLAKE2b-256"
-    BLAKE2B_384 = "BLAKE2b-384"
-    BLAKE2B_512 = "BLAKE2b-512"
-    BLAKE3 = "BLAKE3"
-
-
-_CHECKSUM_VALUES = frozenset(e.value for e in ChecksumAlgorithm)
-_CHECKSUM_NAME_TO_VALUE = {e.name: e.value for e in ChecksumAlgorithm}
-
-
-class ArtifactType(StrEnum):
-    """Type of a TEA artifact (e.g. BOM, VEX, attestation)."""
-
-    ATTESTATION = "ATTESTATION"
-    BOM = "BOM"
-    BUILD_META = "BUILD_META"
-    CERTIFICATION = "CERTIFICATION"
-    FORMULATION = "FORMULATION"
-    LICENSE = "LICENSE"
-    RELEASE_NOTES = "RELEASE_NOTES"
-    SECURITY_TXT = "SECURITY_TXT"
-    THREAT_MODEL = "THREAT_MODEL"
-    VULNERABILITIES = "VULNERABILITIES"
-    OTHER = "OTHER"
-
-
-class CollectionBelongsTo(StrEnum):
-    """Whether a collection belongs to a component release or product release."""
-
-    COMPONENT_RELEASE = "COMPONENT_RELEASE"
-    PRODUCT_RELEASE = "PRODUCT_RELEASE"
-
-
-class CollectionUpdateReasonType(StrEnum):
-    """Reason for a collection version update."""
-
-    INITIAL_RELEASE = "INITIAL_RELEASE"
-    VEX_UPDATED = "VEX_UPDATED"
-    ARTIFACT_UPDATED = "ARTIFACT_UPDATED"
-    ARTIFACT_ADDED = "ARTIFACT_ADDED"
-    ARTIFACT_REMOVED = "ARTIFACT_REMOVED"
-
-
-class ErrorType(StrEnum):
-    """TEA API error types returned in 404 responses."""
-
-    OBJECT_UNKNOWN = "OBJECT_UNKNOWN"
-    OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE"
-
-
-# --- Shared types ---
-
-
-class Identifier(_TeaModel):
-    """An identifier with a specified type (e.g. PURL, CPE, TEI)."""
-
-    id_type: IdentifierType
-    id_value: str
-
-
-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.
-    """
-
-    algorithm_type: ChecksumAlgorithm = Field(alias="algType")
-    algorithm_value: str = Field(alias="algValue")
-
-    @field_validator("algorithm_type", mode="before")
-    @classmethod
-    def normalize_algorithm_type(cls, v: str) -> str:
-        """Normalize underscore form (SHA_256) to hyphen form (SHA-256).
-
-        Uses member-name lookup instead of blind replace to handle
-        BLAKE2b casing correctly (BLAKE2B_256 -> BLAKE2b-256).
-        """
-        if isinstance(v, str) and v not in _CHECKSUM_VALUES:
-            mapped = _CHECKSUM_NAME_TO_VALUE.get(v)
-            if mapped is not None:
-                return mapped
-        return v
-
-
-# --- Domain objects ---
-
-
-class ReleaseDistribution(_TeaModel):
-    """A distribution format for a component release (e.g. binary, source)."""
-
-    distribution_type: str
-    description: str | None = None
-    identifiers: list[Identifier] = []
-    url: str | None = None
-    signature_url: str | None = None
-    checksums: list[Checksum] = []
-
-
-class ArtifactFormat(_TeaModel):
-    """A TEA artifact in a specific format with download URL and checksums."""
-
-    media_type: str
-    description: str | None = None
-    url: str
-    signature_url: str | None = None
-    checksums: list[Checksum] = []
-
-
-class Artifact(_TeaModel):
-    """A security-related artifact (e.g. SBOM, VEX, attestation) with available formats."""
-
-    uuid: str
-    name: str
-    type: ArtifactType
-    distribution_types: list[str] | None = None
-    formats: list[ArtifactFormat] = []
-
-
-class CollectionUpdateReason(_TeaModel):
-    """Reason for a collection version update, with optional comment."""
-
-    type: CollectionUpdateReasonType
-    comment: str | None = None
-
-
-class Collection(_TeaModel):
-    """A versioned collection of artifacts belonging to a release.
-
-    The UUID matches the owning component or product release. The version
-    integer starts at 1 and increments on each content change.
-    """
-
-    uuid: str
-    version: int
-    date: datetime | None = None
-    belongs_to: CollectionBelongsTo | None = None
-    update_reason: CollectionUpdateReason | None = None
-    artifacts: list[Artifact] = []
-
-
-class ComponentRef(_TeaModel):
-    """Reference to a TEA component, optionally pinned to a specific release."""
-
-    uuid: str
-    release: str | None = None
-
-
-class Component(_TeaModel):
-    """A TEA component (software lineage/family, not a specific version)."""
-
-    uuid: str
-    name: str
-    identifiers: list[Identifier]
-
-
-class Release(_TeaModel):
-    """A specific version of a TEA component with distributions and identifiers."""
-
-    uuid: str
-    component: str | None = None
-    component_name: str | None = None
-    version: str
-    created_date: datetime
-    release_date: datetime | None = None
-    pre_release: bool | None = None
-    identifiers: list[Identifier] = []
-    distributions: list[ReleaseDistribution] = []
-
-
-class ComponentReleaseWithCollection(_TeaModel):
-    """A component release bundled with its latest collection.
-
-    Returned by ``GET /componentRelease/{uuid}``.
-    """
-
-    release: Release
-    latest_collection: Collection
-
-
-class Product(_TeaModel):
-    """A TEA product (optional grouping of components)."""
-
-    uuid: str
-    name: str
-    identifiers: list[Identifier]
-
-
-class ProductRelease(_TeaModel):
-    """A specific version of a TEA product with its component references.
-
-    This is the primary entry point from TEI discovery.
-    """
-
-    uuid: str
-    product: str | None = None
-    product_name: str | None = None
-    version: str
-    created_date: datetime
-    release_date: datetime | None = None
-    pre_release: bool | None = None
-    identifiers: list[Identifier] = []
-    components: list[ComponentRef]
-
-
-class ErrorResponse(_TeaModel):
-    """Error response body from TEA API 404 responses."""
-
-    error: ErrorType
-
-
-# --- Pagination ---
-
-
-class PaginatedProductResponse(_TeaModel):
-    """Paginated response containing a list of products."""
-
-    timestamp: datetime
-    page_start_index: int
-    page_size: int
-    total_results: int
-    results: list[Product] = []
-
-
-class PaginatedProductReleaseResponse(_TeaModel):
-    """Paginated response containing a list of product releases."""
-
-    timestamp: datetime
-    page_start_index: int
-    page_size: int
-    total_results: int
-    results: list[ProductRelease] = []
-
-
-# --- Discovery types ---
-
-
-class TeaEndpoint(_TeaModel):
-    """A TEA server endpoint from the .well-known/tea discovery document."""
-
-    url: str
-    versions: list[str] = Field(min_length=1)
-    priority: float | None = Field(default=None, ge=0, le=1)
-
-
-class TeaWellKnown(_TeaModel):
-    """The .well-known/tea discovery document listing available TEA endpoints."""
-
-    schema_version: Literal[1]
-    endpoints: list[TeaEndpoint] = Field(min_length=1)
-
-
-class TeaServerInfo(_TeaModel):
-    """TEA server info returned from the discovery API endpoint."""
-
-    root_url: str
-    versions: list[str] = Field(min_length=1)
-    priority: float | None = Field(default=None, ge=0, le=1)
-
-
-class DiscoveryInfo(_TeaModel):
-    """Discovery result mapping a TEI to a product release and its servers."""
-
-    product_release_uuid: str
-    servers: list[TeaServerInfo]
diff --git a/pyproject.toml b/pyproject.toml
index 9ae1b5d..c1a476d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "libtea"
-version = "0.1.1"
+version = "0.2.0"
 description = "Python client library for the Transparency Exchange API (TEA)"
 authors = [{ name = "sbomify", email = "hello@sbomify.com" }]
 requires-python = ">=3.11"
@@ -20,8 +20,9 @@ classifiers = [
     "Topic :: Software Development :: Libraries :: Python Modules",
 ]
 dependencies = [
-    "requests>=2.32.0,<3",
-    "pydantic>=2.12.0,<3",
+    "requests>=2.32.4,<3",
+    "pydantic>=2.1.0,<3",
+    "semver>=3.0.4,<4",
 ]
 
 [project.urls]
@@ -31,6 +32,12 @@ Documentation = "https://github.com/sbomify/py-libtea#readme"
 "Bug Tracker" = "https://github.com/sbomify/py-libtea/issues"
 Changelog = "https://github.com/sbomify/py-libtea/releases"
 
+[project.optional-dependencies]
+cli = ["typer>=0.12.0,<1", "rich>=13.0.0"]
+
+[project.scripts]
+tea-cli = "libtea._cli_entry:main"
+
 [dependency-groups]
 dev = [
     "pytest>=9.0.0,<10",
@@ -38,10 +45,14 @@ dev = [
     "ruff>=0.15.0,<0.16",
     "pre-commit>=4.5.0,<5",
     "responses>=0.26.0,<1",
+    "mypy>=1.15.0,<2",
+    "types-requests>=2.32.0",
+    "typer>=0.12.0,<1",
+    "rich>=13.0.0",
 ]
 
 [tool.hatch.build.targets.wheel]
-packages = ["libtea"]
+packages = ["src/libtea"]
 
 [build-system]
 requires = ["hatchling"]
@@ -50,7 +61,7 @@ build-backend = "hatchling.build"
 [tool.pytest.ini_options]
 testpaths = ["tests"]
 python_files = ["test_*.py"]
-addopts = "--cov=libtea --cov-report=term-missing --cov-branch"
+addopts = "--cov=src/libtea --cov-report=term-missing --cov-branch"
 
 [tool.ruff]
 line-length = 120
@@ -63,3 +74,18 @@ ignore = ["E501"]
 [tool.ruff.format]
 quote-style = "double"
 indent-style = "space"
+
+[tool.mypy]
+files = ["src/libtea"]
+plugins = ["pydantic.mypy"]
+strict = true
+warn_unused_ignores = true
+enable_error_code = ["ignore-without-code"]
+
+[[tool.mypy.overrides]]
+module = "libtea.cli"
+disallow_untyped_decorators = false
+
+[[tool.mypy.overrides]]
+module = "libtea._cli_fmt"
+disallow_untyped_decorators = false
diff --git a/libtea/__init__.py b/src/libtea/__init__.py
similarity index 63%
rename from libtea/__init__.py
rename to src/libtea/__init__.py
index 69a42ca..4b0527d 100644
--- a/libtea/__init__.py
+++ b/src/libtea/__init__.py
@@ -1,8 +1,22 @@
-"""libtea - Python client library for the Transparency Exchange API (TEA)."""
+"""libtea — Python client library for the Transparency Exchange API (TEA).
+
+Quick start::
+
+    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")
+
+Or auto-discover the server from a domain's ``.well-known/tea``::
+
+    client = TeaClient.from_well_known("tea.example.com", token="...")
+"""
 
 from importlib.metadata import version
 
+from libtea._http import MtlsConfig
 from libtea.client import TEA_SPEC_VERSION, TeaClient
+from libtea.discovery import fetch_well_known, parse_tei, select_endpoint, select_endpoints
 from libtea.exceptions import (
     TeaAuthenticationError,
     TeaChecksumError,
@@ -16,11 +30,17 @@
     TeaValidationError,
 )
 from libtea.models import (
+    CLE,
     Artifact,
     ArtifactFormat,
     ArtifactType,
     Checksum,
     ChecksumAlgorithm,
+    CLEDefinitions,
+    CLEEvent,
+    CLEEventType,
+    CLESupportDefinition,
+    CLEVersionSpecifier,
     Collection,
     CollectionBelongsTo,
     CollectionUpdateReason,
@@ -38,14 +58,24 @@
     ProductRelease,
     Release,
     ReleaseDistribution,
+    TeaEndpoint,
     TeaServerInfo,
+    TeaWellKnown,
     TeiType,
 )
 
 __version__ = version("libtea")
 __all__ = [
+    # Client
+    "MtlsConfig",
     "TEA_SPEC_VERSION",
     "TeaClient",
+    # Discovery
+    "fetch_well_known",
+    "parse_tei",
+    "select_endpoint",
+    "select_endpoints",
+    # Exceptions
     "TeaError",
     "TeaAuthenticationError",
     "TeaChecksumError",
@@ -56,9 +86,16 @@
     "TeaRequestError",
     "TeaServerError",
     "TeaValidationError",
+    # Models
     "Artifact",
     "ArtifactFormat",
     "ArtifactType",
+    "CLE",
+    "CLEDefinitions",
+    "CLEEvent",
+    "CLEEventType",
+    "CLESupportDefinition",
+    "CLEVersionSpecifier",
     "Checksum",
     "ChecksumAlgorithm",
     "Collection",
@@ -78,7 +115,9 @@
     "ProductRelease",
     "Release",
     "ReleaseDistribution",
+    "TeaEndpoint",
     "TeaServerInfo",
+    "TeaWellKnown",
     "TeiType",
     "__version__",
 ]
diff --git a/src/libtea/_cli_entry.py b/src/libtea/_cli_entry.py
new file mode 100644
index 0000000..4a338f6
--- /dev/null
+++ b/src/libtea/_cli_entry.py
@@ -0,0 +1,13 @@
+"""Entry point wrapper for tea-cli that handles missing typer gracefully."""
+
+import sys
+
+
+def main() -> None:
+    """Launch the tea-cli app, or print a helpful error if typer is not installed."""
+    try:
+        from libtea.cli import app
+    except ImportError:
+        print("Error: CLI dependencies not installed. Run: pip install libtea[cli]", file=sys.stderr)
+        raise SystemExit(1)
+    app()
diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py
new file mode 100644
index 0000000..972d2e0
--- /dev/null
+++ b/src/libtea/_cli_fmt.py
@@ -0,0 +1,542 @@
+"""Rich formatters for CLI output.
+
+Each ``fmt_*`` function renders a specific TEA model type as a rich table or
+panel.  :func:`format_output` dispatches by type or by explicit ``command``
+name (``"discover"`` and ``"inspect"`` use command-based dispatch because
+their data is ``list`` which is ambiguous by type alone).
+"""
+
+import json
+from collections.abc import Sequence
+from typing import Any
+
+from pydantic import BaseModel
+from rich.console import Console
+from rich.markup import escape
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+
+from libtea.models import (
+    CLE,
+    Artifact,
+    ArtifactFormat,
+    Collection,
+    Component,
+    ComponentReleaseWithCollection,
+    DiscoveryInfo,
+    Identifier,
+    PaginatedProductReleaseResponse,
+    PaginatedProductResponse,
+    Product,
+    ProductRelease,
+    Release,
+    ReleaseDistribution,
+)
+
+_console = Console()
+
+
+# --- Helpers ---
+
+
+def _opt(value: object) -> str:
+    """Return ``"-"`` for ``None``, otherwise ``str(value)``."""
+    return "-" if value is None else str(value)
+
+
+def _esc(value: object) -> str:
+    """Like :func:`_opt` but also escapes Rich markup for safe table rendering."""
+    return escape(_opt(value))
+
+
+def _fmt_identifiers(identifiers: Sequence[Identifier]) -> str:
+    """Format a list of :class:`Identifier` objects as comma-joined ``type:value``."""
+    if not identifiers:
+        return "-"
+    return ", ".join(f"{i.id_type}:{i.id_value}" for i in identifiers)
+
+
+def _kv_panel(title: str, fields: list[tuple[str, str]], *, console: Console) -> None:
+    """Render a key-value panel with aligned labels."""
+    lines: list[str] = []
+    for label, value in fields:
+        lines.append(f"[bold]{escape(label)}:[/bold] {escape(value)}")
+    console.print(Panel("\n".join(lines), title=escape(title), expand=False))
+
+
+def _pagination_header(data: PaginatedProductResponse | PaginatedProductReleaseResponse, *, console: Console) -> None:
+    """Render a dim pagination summary line."""
+    if 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"))
+
+
+def _distributions_table(distributions: Sequence[ReleaseDistribution], *, console: Console) -> None:
+    """Render a table of :class:`ReleaseDistribution` objects."""
+    if not distributions:
+        return
+    tbl = Table(title="Distributions")
+    tbl.add_column("Type")
+    tbl.add_column("Description")
+    tbl.add_column("URL")
+    tbl.add_column("Signature URL")
+    tbl.add_column("Checksums")
+    for d in distributions:
+        checksums = ", ".join(f"{cs.algorithm_type}:{cs.algorithm_value[:12]}..." for cs in d.checksums) or "-"
+        tbl.add_row(
+            _esc(d.distribution_type), _esc(d.description), _esc(d.url), _esc(d.signature_url), escape(checksums)
+        )
+    console.print(tbl)
+
+
+def _artifacts_table(artifacts: Sequence[Artifact], *, console: Console) -> None:
+    """Render a table of :class:`Artifact` model objects."""
+    if not artifacts:
+        return
+    tbl = Table(title="Artifacts")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Name")
+    tbl.add_column("Type")
+    tbl.add_column("Applies To")
+    tbl.add_column("Formats")
+    for a in artifacts:
+        fmt_str = ", ".join(f.media_type for f in a.formats) or "-"
+        applies = ", ".join(a.distribution_types) if a.distribution_types else "-"
+        tbl.add_row(escape(a.uuid), escape(a.name), escape(a.type), escape(applies), escape(fmt_str))
+    console.print(tbl)
+
+
+def _formats_table(formats: Sequence[ArtifactFormat], *, console: Console) -> None:
+    """Render a table of artifact formats with checksums."""
+    if not formats:
+        return
+    tbl = Table(title="Formats")
+    tbl.add_column("Media Type")
+    tbl.add_column("Description")
+    tbl.add_column("URL")
+    tbl.add_column("Signature URL")
+    tbl.add_column("Checksums")
+    for f in formats:
+        checksums = ", ".join(f"{cs.algorithm_type}:{cs.algorithm_value[:12]}..." for cs in f.checksums) or "-"
+        tbl.add_row(escape(f.media_type), _esc(f.description), escape(f.url), _esc(f.signature_url), escape(checksums))
+    console.print(tbl)
+
+
+# --- Per-command formatters ---
+
+
+def fmt_discover(data: list[DiscoveryInfo], *, console: Console) -> None:
+    """Render discovery results as a table."""
+    tbl = Table(title="Discovery Results")
+    tbl.add_column("Product Release UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Server URL")
+    tbl.add_column("API Versions")
+    tbl.add_column("Priority", justify="right")
+    for d in data:
+        for s in d.servers:
+            tbl.add_row(
+                escape(d.product_release_uuid), escape(s.root_url), escape(", ".join(s.versions)), _esc(s.priority)
+            )
+    console.print(tbl)
+
+
+def fmt_search_products(data: PaginatedProductResponse, *, console: Console) -> None:
+    """Render paginated product search results."""
+    _pagination_header(data, console=console)
+    tbl = Table(title="Products")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Name")
+    tbl.add_column("Identifiers")
+    for p in data.results:
+        tbl.add_row(escape(p.uuid), escape(p.name), escape(_fmt_identifiers(p.identifiers)))
+    console.print(tbl)
+
+
+def fmt_search_releases(data: PaginatedProductReleaseResponse, *, console: Console) -> None:
+    """Render paginated product-release search results."""
+    _pagination_header(data, console=console)
+    tbl = Table(title="Product Releases")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Version")
+    tbl.add_column("Product")
+    tbl.add_column("Release Date")
+    tbl.add_column("Pre-release")
+    for r in data.results:
+        tbl.add_row(escape(r.uuid), escape(r.version), _esc(r.product_name), _esc(r.release_date), _esc(r.pre_release))
+    console.print(tbl)
+
+
+def fmt_product(data: Product, *, console: Console) -> None:
+    """Render a single product as a panel."""
+    _kv_panel(
+        "Product",
+        [("UUID", data.uuid), ("Name", data.name), ("Identifiers", _fmt_identifiers(data.identifiers))],
+        console=console,
+    )
+
+
+def fmt_product_release(data: ProductRelease, *, console: Console) -> None:
+    """Render a product release as a panel with component refs."""
+    _kv_panel(
+        "Product Release",
+        [
+            ("UUID", data.uuid),
+            ("Version", data.version),
+            ("Product", _opt(data.product_name)),
+            ("Created", str(data.created_date)),
+            ("Released", _opt(data.release_date)),
+            ("Pre-release", _opt(data.pre_release)),
+            ("Identifiers", _fmt_identifiers(data.identifiers)),
+        ],
+        console=console,
+    )
+    if data.components:
+        tbl = Table(title="Components")
+        tbl.add_column("UUID", style="cyan", no_wrap=True)
+        tbl.add_column("Release UUID")
+        for comp in data.components:
+            tbl.add_row(escape(comp.uuid), _esc(comp.release))
+        console.print(tbl)
+
+
+def fmt_component_release(data: ComponentReleaseWithCollection, *, console: Console) -> None:
+    """Render a component release + its latest collection."""
+    r = data.release
+    _kv_panel(
+        "Component Release",
+        [
+            ("UUID", r.uuid),
+            ("Version", r.version),
+            ("Component", _opt(r.component_name)),
+            ("Created", str(r.created_date)),
+            ("Released", _opt(r.release_date)),
+            ("Pre-release", _opt(r.pre_release)),
+            ("Identifiers", _fmt_identifiers(r.identifiers)),
+        ],
+        console=console,
+    )
+    _distributions_table(r.distributions, console=console)
+    col = data.latest_collection
+    _kv_panel(
+        "Latest Collection",
+        [
+            ("UUID", _opt(col.uuid)),
+            ("Version", _opt(col.version)),
+            ("Date", _opt(col.date)),
+            ("Belongs To", _opt(col.belongs_to)),
+        ],
+        console=console,
+    )
+    _artifacts_table(col.artifacts, console=console)
+
+
+def fmt_collection(data: Collection, *, console: Console) -> None:
+    """Render a collection as a panel with artifacts table."""
+    reason = "-"
+    if data.update_reason:
+        reason = data.update_reason.type
+        if data.update_reason.comment:
+            reason += f" ({data.update_reason.comment})"
+    _kv_panel(
+        "Collection",
+        [
+            ("UUID", _opt(data.uuid)),
+            ("Version", _opt(data.version)),
+            ("Date", _opt(data.date)),
+            ("Belongs To", _opt(data.belongs_to)),
+            ("Update Reason", reason),
+        ],
+        console=console,
+    )
+    _artifacts_table(data.artifacts, console=console)
+
+
+def fmt_artifact(data: Artifact, *, console: Console) -> None:
+    """Render artifact metadata as a panel with formats table."""
+    _kv_panel(
+        "Artifact",
+        [("UUID", data.uuid), ("Name", data.name), ("Type", data.type)],
+        console=console,
+    )
+    _formats_table(data.formats, console=console)
+
+
+def fmt_component(data: Component, *, console: Console) -> None:
+    """Render a single component as a panel."""
+    _kv_panel(
+        "Component",
+        [("UUID", data.uuid), ("Name", data.name), ("Identifiers", _fmt_identifiers(data.identifiers))],
+        console=console,
+    )
+
+
+def fmt_releases(data: list[Release], *, console: Console) -> None:
+    """Render a list of component releases as a table."""
+    tbl = Table(title="Component Releases")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Version")
+    tbl.add_column("Component")
+    tbl.add_column("Created")
+    tbl.add_column("Released")
+    tbl.add_column("Pre-release")
+    tbl.add_column("Identifiers")
+    for r in data:
+        tbl.add_row(
+            escape(r.uuid),
+            escape(r.version),
+            _esc(r.component_name),
+            escape(str(r.created_date)),
+            _esc(r.release_date),
+            _esc(r.pre_release),
+            escape(_fmt_identifiers(r.identifiers)),
+        )
+    console.print(tbl)
+
+
+def fmt_collections(data: list[Collection], *, console: Console) -> None:
+    """Render a list of collections as a table."""
+    tbl = Table(title="Collections")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Version", justify="right")
+    tbl.add_column("Date")
+    tbl.add_column("Belongs To")
+    tbl.add_column("Artifacts")
+    for col in data:
+        tbl.add_row(
+            _esc(col.uuid),
+            _esc(col.version),
+            _esc(col.date),
+            _esc(col.belongs_to),
+            str(len(col.artifacts)),
+        )
+    console.print(tbl)
+
+
+def fmt_cle(data: CLE, *, console: Console) -> None:
+    """Render a CLE document with events table and optional definitions."""
+    if data.definitions and data.definitions.support:
+        tbl = Table(title="Support Definitions")
+        tbl.add_column("ID", style="cyan")
+        tbl.add_column("Description")
+        tbl.add_column("URL")
+        for defn in data.definitions.support:
+            tbl.add_row(escape(defn.id), escape(defn.description), _esc(defn.url))
+        console.print(tbl)
+
+    tbl = Table(title="Lifecycle Events")
+    tbl.add_column("ID", justify="right")
+    tbl.add_column("Type", style="bold")
+    tbl.add_column("Effective")
+    tbl.add_column("Published")
+    tbl.add_column("Version")
+    tbl.add_column("Details")
+    for ev in data.events:
+        details_parts: list[str] = []
+        if ev.support_id:
+            details_parts.append(f"support={ev.support_id}")
+        if ev.license:
+            details_parts.append(f"license={ev.license}")
+        if ev.superseded_by_version:
+            details_parts.append(f"superseded_by={ev.superseded_by_version}")
+        if ev.reason:
+            details_parts.append(f"reason={ev.reason}")
+        if ev.event_id is not None:
+            details_parts.append(f"event_id={ev.event_id}")
+        details = ", ".join(details_parts) or "-"
+        version = ev.version or "-"
+        if ev.versions:
+            ranges = ", ".join(v.version or v.range or "?" for v in ev.versions)
+            version = ranges
+        tbl.add_row(
+            str(ev.id),
+            escape(ev.type.value),
+            escape(str(ev.effective)),
+            escape(str(ev.published)),
+            escape(version),
+            escape(details),
+        )
+    console.print(tbl)
+
+
+def fmt_inspect(data: list[dict[str, Any]], *, console: Console) -> None:
+    """Render the full inspect output (discovery + release + components)."""
+    for entry in data:
+        # Discovery servers
+        disc = entry.get("discovery")
+        if disc:
+            servers = disc.get("servers", [])
+            if servers:
+                tbl = Table(title="Discovery Servers")
+                tbl.add_column("Server URL")
+                tbl.add_column("API Versions")
+                tbl.add_column("Priority", justify="right")
+                for s in servers:
+                    tbl.add_row(
+                        escape(s.get("rootUrl", "-")),
+                        escape(", ".join(s.get("versions", []))),
+                        _esc(s.get("priority")),
+                    )
+                console.print(tbl)
+
+        pr = entry["productRelease"]
+        fields = [
+            ("UUID", pr["uuid"]),
+            ("Product", _opt(pr.get("productName"))),
+            ("Version", pr["version"]),
+            ("Created", str(pr.get("createdDate", "-"))),
+            ("Released", _opt(pr.get("releaseDate"))),
+            ("Pre-release", _opt(pr.get("preRelease"))),
+        ]
+        identifiers = pr.get("identifiers", [])
+        if identifiers:
+            id_str = ", ".join(f"{i['idType']}:{i['idValue']}" for i in identifiers)
+            fields.append(("Identifiers", id_str))
+        _kv_panel("Product Release", fields, console=console)
+        components = entry.get("components", [])
+        if components:
+            tbl = Table(title="Components")
+            tbl.add_column("UUID", style="cyan", no_wrap=True)
+            tbl.add_column("Version")
+            tbl.add_column("Name")
+            tbl.add_column("Note", style="dim")
+            for comp in components:
+                comp_uuid = comp.get("uuid") or comp.get("release", {}).get("uuid", "-")
+                version = comp.get("version") or comp.get("release", {}).get("version", "-")
+                name = comp.get("name") or comp.get("release", {}).get("componentName", "-")
+                note = comp.get("resolvedNote", "")
+                tbl.add_row(escape(str(comp_uuid)), escape(str(version)), _esc(name), escape(note))
+            console.print(tbl)
+            # Show artifact details for each component
+            for comp in components:
+                _inspect_component_details(comp, console=console)
+        if entry.get("truncated"):
+            console.print(Text(f"Showing {len(components)} of {entry['totalComponents']} components", style="dim"))
+
+
+def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> None:
+    """Render distributions and artifact details for a component in inspect output."""
+    # Distributions come from the release object
+    release = comp.get("release") or (comp.get("resolvedRelease") or {}).get("release") or {}
+    distributions = release.get("distributions") or []
+    if distributions:
+        comp_name = comp.get("name") or release.get("componentName", "Component")
+        tbl = Table(title=f"Distributions ({_esc(comp_name)})")
+        tbl.add_column("Type")
+        tbl.add_column("Description")
+        tbl.add_column("URL")
+        tbl.add_column("Signature URL")
+        tbl.add_column("Checksums")
+        for d in distributions:
+            checksums_list = d.get("checksums") or []
+            checksums = (
+                ", ".join(f"{cs.get('algType', '?')}:{cs.get('algValue', '')[:12]}..." for cs in checksums_list) or "-"
+            )
+            tbl.add_row(
+                escape(d.get("distributionType", "-")),
+                _esc(d.get("description")),
+                _esc(d.get("url")),
+                _esc(d.get("signatureUrl")),
+                escape(checksums),
+            )
+        console.print(tbl)
+
+    # Artifacts come from either a direct componentRelease or a resolvedRelease
+    release_data = comp.get("latestCollection") or (comp.get("resolvedRelease") or {}).get("latestCollection")
+    if not release_data:
+        return
+    artifacts = release_data.get("artifacts", [])
+    if not artifacts:
+        return
+    comp_name = comp.get("name") or comp.get("release", {}).get("componentName", "Component")
+    tbl = Table(title=f"Artifacts ({escape(str(comp_name))})")
+    tbl.add_column("UUID", style="cyan", no_wrap=True)
+    tbl.add_column("Name")
+    tbl.add_column("Type")
+    tbl.add_column("Applies To")
+    tbl.add_column("Media Type")
+    tbl.add_column("Description")
+    tbl.add_column("URL")
+    tbl.add_column("Signature URL")
+    for art in artifacts:
+        applies = ", ".join(art.get("distributionTypes") or []) or "-"
+        formats = art.get("formats", [])
+        if formats:
+            for fmt in formats:
+                tbl.add_row(
+                    escape(art.get("uuid", "-")),
+                    escape(art.get("name", "-")),
+                    escape(art.get("type", "-")),
+                    escape(applies),
+                    escape(fmt.get("mediaType", "-")),
+                    _esc(fmt.get("description")),
+                    escape(fmt.get("url", "-")),
+                    _esc(fmt.get("signatureUrl")),
+                )
+        else:
+            tbl.add_row(
+                escape(art.get("uuid", "-")),
+                escape(art.get("name", "-")),
+                escape(art.get("type", "-")),
+                escape(applies),
+                "-",
+                "-",
+                "-",
+                "-",
+            )
+    console.print(tbl)
+
+
+# --- Dispatch ---
+
+_TYPE_FORMATTERS = {
+    Product: fmt_product,
+    ProductRelease: fmt_product_release,
+    ComponentReleaseWithCollection: fmt_component_release,
+    Collection: fmt_collection,
+    Artifact: fmt_artifact,
+    Component: fmt_component,
+    CLE: fmt_cle,
+    PaginatedProductResponse: fmt_search_products,
+    PaginatedProductReleaseResponse: fmt_search_releases,
+}
+
+
+def format_output(data: object, *, command: str | None = None, console: Console | None = None) -> None:
+    """Dispatch *data* to the appropriate rich formatter.
+
+    Falls back to :meth:`Console.print_json` for unrecognised types.
+    """
+    c = console or _console
+
+    if command == "inspect" and isinstance(data, list):
+        fmt_inspect(data, console=c)
+        return
+
+    if command == "discover" and isinstance(data, list):
+        fmt_discover(data, console=c)
+        return
+
+    if command == "releases" and isinstance(data, list):
+        fmt_releases(data, console=c)
+        return
+
+    if command == "collections" and isinstance(data, list):
+        fmt_collections(data, console=c)
+        return
+
+    for model_type, formatter in _TYPE_FORMATTERS.items():
+        if isinstance(data, model_type):
+            formatter(data, console=c)  # type: ignore[operator]
+            return
+
+    # Fallback: render as JSON
+    if isinstance(data, BaseModel):
+        c.print_json(json.dumps(data.model_dump(mode="json", by_alias=True), default=str))
+    elif isinstance(data, list):
+        items = [item.model_dump(mode="json", by_alias=True) if isinstance(item, BaseModel) else item for item in data]
+        c.print_json(json.dumps(items, default=str))
+    else:
+        c.print_json(json.dumps(data, default=str))
diff --git a/src/libtea/_hashing.py b/src/libtea/_hashing.py
new file mode 100644
index 0000000..0874b28
--- /dev/null
+++ b/src/libtea/_hashing.py
@@ -0,0 +1,63 @@
+"""Checksum hash builder for TEA artifact verification.
+
+Maps TEA algorithm names to ``hashlib`` hash objects. Used by
+:meth:`~libtea._http.TeaHttpClient.download_with_hashes` to compute
+digests on-the-fly during streaming downloads.
+"""
+
+import hashlib
+from typing import Any
+
+from libtea.exceptions import TeaChecksumError
+
+# Hash algorithm registry: {TEA name: (hashlib name, digest_size)}.
+# When digest_size is None, hashlib.new(name) is used with its default size.
+# When digest_size is set, hashlib.blake2b(digest_size=N) is used instead.
+# BLAKE3 is intentionally excluded — handled separately in _build_hashers.
+_HASH_REGISTRY: dict[str, tuple[str, int | None]] = {
+    "MD5": ("md5", None),
+    "SHA-1": ("sha1", None),
+    "SHA-256": ("sha256", None),
+    "SHA-384": ("sha384", None),
+    "SHA-512": ("sha512", None),
+    "SHA3-256": ("sha3_256", None),
+    "SHA3-384": ("sha3_384", None),
+    "SHA3-512": ("sha3_512", None),
+    "BLAKE2b-256": ("blake2b", 32),
+    "BLAKE2b-384": ("blake2b", 48),
+    "BLAKE2b-512": ("blake2b", 64),
+}
+
+
+def _build_hashers(algorithms: list[str]) -> dict[str, Any]:
+    """Build ``hashlib`` hasher objects for the given TEA algorithm names.
+
+    Args:
+        algorithms: List of TEA checksum algorithm names (e.g. ``["SHA-256", "BLAKE2b-256"]``).
+
+    Returns:
+        Dict mapping algorithm name to a fresh hashlib hash object.
+
+    Raises:
+        TeaChecksumError: If BLAKE3 is requested (not in stdlib) or the algorithm is unknown.
+    """
+    hashers: dict[str, Any] = {}
+    for alg in algorithms:
+        if alg == "BLAKE3":
+            raise TeaChecksumError(
+                "BLAKE3 is not supported by Python's hashlib. "
+                "Install the 'blake3' package or use a different algorithm.",
+                algorithm="BLAKE3",
+            )
+        entry = _HASH_REGISTRY.get(alg)
+        if entry is None:
+            raise TeaChecksumError(
+                f"Unsupported checksum algorithm: {alg!r}. Supported: {', '.join(sorted(_HASH_REGISTRY.keys()))}",
+                algorithm=alg,
+            )
+        hashlib_name, digest_size = entry
+        if digest_size is not None:
+            hashers[alg] = hashlib.blake2b(digest_size=digest_size)
+        else:
+            hashers[alg] = hashlib.new(hashlib_name)
+    return hashers
diff --git a/src/libtea/_http.py b/src/libtea/_http.py
new file mode 100644
index 0000000..34c58b6
--- /dev/null
+++ b/src/libtea/_http.py
@@ -0,0 +1,383 @@
+"""Internal HTTP client wrapping ``requests`` with TEA-specific error handling.
+
+This module is an implementation detail. Public consumers should use
+:class:`~libtea.client.TeaClient` instead.
+"""
+
+import logging
+import warnings
+from dataclasses import dataclass
+from pathlib import Path
+from types import TracebackType
+from typing import Any, Self
+from urllib.parse import urljoin, urlparse
+
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from libtea._hashing import _build_hashers
+from libtea._security import _validate_download_url
+from libtea.exceptions import (
+    TeaAuthenticationError,
+    TeaConnectionError,
+    TeaInsecureTransportWarning,
+    TeaNotFoundError,
+    TeaRequestError,
+    TeaServerError,
+    TeaValidationError,
+)
+
+logger = logging.getLogger("libtea")
+
+
+def _get_package_version() -> str:
+    """Get the package version for User-Agent header."""
+    try:
+        from importlib.metadata import PackageNotFoundError, version
+
+        return version("libtea")
+    except (PackageNotFoundError, ValueError):
+        return "unknown"
+
+
+USER_AGENT = f"py-libtea/{_get_package_version()} (hello@sbomify.com)"
+
+
+@dataclass(frozen=True)
+class MtlsConfig:
+    """Client certificate configuration for mutual TLS (mTLS).
+
+    Attributes:
+        client_cert: Path to the PEM-encoded client certificate.
+        client_key: Path to the PEM-encoded client private key.
+        ca_bundle: Optional path to a CA bundle for server certificate
+            verification. When ``None``, the system default CA store is used.
+    """
+
+    client_cert: Path
+    client_key: Path
+    ca_bundle: Path | None = None
+
+
+_MAX_DOWNLOAD_REDIRECTS = 10
+
+
+def probe_endpoint(url: str, timeout: float = 5.0, mtls: MtlsConfig | None = None) -> None:
+    """Probe a URL to verify the server is reachable.
+
+    Uses a standalone HEAD request with no auth and no retries so that
+    failover between candidates is fast.
+
+    Args:
+        url: Endpoint URL to probe.
+        timeout: Request timeout in seconds.
+        mtls: Optional mutual TLS configuration for mTLS-only deployments.
+
+    Raises:
+        TeaConnectionError: If the endpoint is unreachable.
+        TeaServerError: If the endpoint returns HTTP 5xx.
+    """
+    kwargs: dict[str, Any] = {
+        "timeout": timeout,
+        "allow_redirects": False,
+        "headers": {"user-agent": USER_AGENT},
+    }
+    if mtls:
+        kwargs["cert"] = (str(mtls.client_cert), str(mtls.client_key))
+        if mtls.ca_bundle:
+            kwargs["verify"] = str(mtls.ca_bundle)
+    try:
+        resp = requests.head(url, **kwargs)
+        resp.close()
+    except requests.RequestException as exc:
+        raise TeaConnectionError(str(exc)) from exc
+    if 300 <= resp.status_code < 400:
+        raise TeaConnectionError(f"Endpoint probe returned redirect: HTTP {resp.status_code}")
+    if resp.status_code >= 500:
+        raise TeaServerError(f"Server error: HTTP {resp.status_code}")
+
+
+class TeaHttpClient:
+    """Low-level HTTP client for TEA API requests.
+
+    Handles authentication headers, error mapping, and streaming downloads.
+    Uses a separate unauthenticated session for artifact downloads to avoid
+    leaking bearer tokens to third-party hosts (CDNs, Maven Central, etc.).
+
+    Args:
+        base_url: TEA server base URL (e.g. ``https://tea.example.com/v1``).
+        token: Optional bearer token. Mutually exclusive with ``basic_auth``.
+            Rejected when ``base_url`` uses plaintext HTTP.
+        basic_auth: Optional ``(username, password)`` tuple for HTTP Basic auth.
+            Mutually exclusive with ``token``. Rejected with plaintext HTTP.
+        timeout: Request timeout in seconds (default 30).
+        mtls: Optional :class:`MtlsConfig` for mutual TLS authentication.
+        max_retries: Number of retries on 5xx responses (default 3). Set to 0 to disable.
+        backoff_factor: Exponential backoff factor between retries (default 0.5).
+
+    Raises:
+        ValueError: If ``base_url`` is invalid, or both ``token`` and ``basic_auth`` are set,
+            or credentials are used with plaintext HTTP.
+    """
+
+    def __init__(
+        self,
+        base_url: str,
+        *,
+        token: str | None = None,
+        basic_auth: tuple[str, str] | None = None,
+        timeout: float = 30.0,
+        mtls: MtlsConfig | None = None,
+        max_retries: int = 3,
+        backoff_factor: float = 0.5,
+    ):
+        parsed = urlparse(base_url)
+        if parsed.scheme not in ("http", "https"):
+            raise ValueError(f"base_url must use http or https scheme, got {parsed.scheme!r}")
+        if not parsed.hostname:
+            raise ValueError(f"base_url must include a hostname: {base_url!r}")
+        if token and basic_auth:
+            raise ValueError("Cannot use both token and basic_auth.")
+        if parsed.scheme == "http" and token:
+            raise ValueError("Cannot use bearer token with plaintext HTTP. Use https:// or remove the token.")
+        if parsed.scheme == "http" and basic_auth:
+            raise ValueError("Cannot use basic auth with plaintext HTTP. Use https:// or remove basic_auth.")
+        if max_retries < 0:
+            raise ValueError(f"max_retries must be >= 0, got {max_retries}")
+        if parsed.scheme == "http":
+            warnings.warn(
+                "Using plaintext HTTP is insecure. Use HTTPS in production.",
+                TeaInsecureTransportWarning,
+                stacklevel=2,
+            )
+        self._base_url = parsed.geturl().rstrip("/")
+        self._timeout = timeout
+        self._max_response_bytes = 10 * 1024 * 1024  # 10 MB default cap for API responses
+        self._session = requests.Session()
+        self._session.headers["user-agent"] = USER_AGENT
+
+        if token:
+            self._session.headers["authorization"] = f"Bearer {token}"
+        elif basic_auth:
+            self._session.auth = basic_auth
+
+        if mtls:
+            self._session.cert = (str(mtls.client_cert), str(mtls.client_key))
+            if mtls.ca_bundle:
+                self._session.verify = str(mtls.ca_bundle)
+
+        retry = Retry(
+            total=max_retries,
+            backoff_factor=backoff_factor,
+            status_forcelist=(500, 502, 503, 504),
+            allowed_methods=["GET", "HEAD", "OPTIONS"],
+            raise_on_status=False,
+            respect_retry_after_header=False,
+        )
+        adapter = HTTPAdapter(max_retries=retry)
+        self._session.mount("https://", adapter)
+        self._session.mount("http://", adapter)
+
+    def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
+        """Send GET request and return parsed JSON.
+
+        Args:
+            path: URL path relative to base URL (e.g. ``/product/{uuid}``).
+            params: Optional query parameters.
+
+        Returns:
+            Parsed JSON response body.
+
+        Raises:
+            TeaConnectionError: On network failure.
+            TeaNotFoundError: On HTTP 404.
+            TeaAuthenticationError: On HTTP 401/403.
+            TeaServerError: On HTTP 5xx.
+        """
+        url = f"{self._base_url}{path}"
+        logger.debug("GET %s params=%s", url, params)
+        try:
+            response = self._session.get(url, params=params, timeout=self._timeout, allow_redirects=False)
+        except requests.ConnectionError as exc:
+            logger.warning("Connection error for %s: %s", url, exc)
+            raise TeaConnectionError(str(exc)) from exc
+        except requests.Timeout as exc:
+            logger.warning("Timeout for %s: %s", url, exc)
+            raise TeaConnectionError(str(exc)) from exc
+        except requests.RequestException as exc:
+            logger.warning("Request error for %s: %s", url, exc)
+            raise TeaConnectionError(str(exc)) from exc
+
+        try:
+            logger.debug("HTTP %d %s (%.3fs)", response.status_code, response.url, response.elapsed.total_seconds())
+            self._raise_for_status(response)
+            content_length = response.headers.get("Content-Length")
+            if content_length and content_length.isdigit() and int(content_length) > self._max_response_bytes:
+                raise TeaValidationError(
+                    f"Response too large: {content_length} bytes (limit {self._max_response_bytes})"
+                )
+            body = response.content
+            if len(body) > self._max_response_bytes:
+                raise TeaValidationError(
+                    f"Response body exceeds limit: {len(body)} bytes (limit {self._max_response_bytes})"
+                )
+            try:
+                return response.json()
+            except ValueError as exc:
+                raise TeaValidationError(f"Invalid JSON in response: {exc}") from exc
+        finally:
+            response.close()
+
+    def download_with_hashes(
+        self,
+        url: str,
+        dest: Path,
+        algorithms: list[str] | None = None,
+        *,
+        max_download_bytes: int | None = None,
+    ) -> dict[str, str]:
+        """Download a file and compute checksums on-the-fly.
+
+        Uses a separate unauthenticated session so that the bearer token
+        is not leaked to third-party artifact hosts (CDNs, Maven Central, etc.).
+        Redirects are followed manually with SSRF validation at each hop.
+
+        Args:
+            url: Direct download URL.
+            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.
+
+        Returns:
+            Dict mapping algorithm name to hex digest string.
+
+        Raises:
+            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)
+        logger.debug("DOWNLOAD %s -> %s", url, dest)
+        hashers = _build_hashers(algorithms) if algorithms else {}
+
+        dest.parent.mkdir(parents=True, exist_ok=True)
+        try:
+            with requests.Session() as download_session:
+                download_session.headers["user-agent"] = USER_AGENT
+
+                # Follow redirects manually with SSRF validation at each hop
+                current_url = url
+                response = None
+                try:
+                    redirects = 0
+                    while True:
+                        response = download_session.get(
+                            current_url, stream=True, timeout=self._timeout, allow_redirects=False
+                        )
+                        if 300 <= response.status_code < 400:
+                            redirects += 1
+                            if redirects > _MAX_DOWNLOAD_REDIRECTS:
+                                raise TeaConnectionError(f"Too many redirects (max {_MAX_DOWNLOAD_REDIRECTS})")
+                            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)
+                            response.close()
+                            response = None
+                            continue
+                        break
+
+                    self._raise_for_status(response)
+
+                    downloaded = 0
+                    with open(dest, "wb") as f:
+                        for chunk in response.iter_content(chunk_size=8192):
+                            downloaded += len(chunk)
+                            if max_download_bytes is not None and downloaded > max_download_bytes:
+                                raise TeaValidationError(f"Download exceeds size limit of {max_download_bytes} bytes")
+                            f.write(chunk)
+                            for h in hashers.values():
+                                h.update(chunk)
+                finally:
+                    if response is not None:
+                        response.close()
+        except (requests.ConnectionError, requests.Timeout) as exc:
+            dest.unlink(missing_ok=True)
+            raise TeaConnectionError(str(exc)) from exc
+        except requests.RequestException as exc:
+            dest.unlink(missing_ok=True)
+            raise TeaConnectionError(f"Download failed: {exc}") from exc
+        except BaseException:
+            try:
+                dest.unlink(missing_ok=True)
+            except OSError:
+                logger.warning("Failed to clean up partial download at %s", dest)
+            raise
+
+        return {alg: h.hexdigest() for alg, h in hashers.items()}
+
+    def close(self) -> None:
+        """Close the HTTP session and clear sensitive credentials from memory."""
+        self._session.headers.pop("authorization", None)
+        self._session.auth = None
+        self._session.cert = None
+        self._session.close()
+
+    def __enter__(self) -> Self:
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> None:
+        self.close()
+
+    @staticmethod
+    def _raise_for_status(response: requests.Response) -> None:
+        """Map HTTP status codes to typed :mod:`~libtea.exceptions`.
+
+        2xx passes through, 3xx raises :class:`TeaRequestError`,
+        401/403 raises :class:`TeaAuthenticationError`, 404 raises
+        :class:`TeaNotFoundError`, 5xx raises :class:`TeaServerError`,
+        and remaining 4xx codes raise :class:`TeaRequestError`.
+        """
+        status = response.status_code
+        if 200 <= status < 300:
+            return
+        if 300 <= status < 400:
+            raise TeaRequestError(f"Unexpected redirect: HTTP {status}")
+        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:
+                body = response.json()
+                if isinstance(body, dict):
+                    error_type = body.get("error")
+            except ValueError:
+                pass
+            raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type)
+        if status >= 500:
+            raise TeaServerError(f"Server error: HTTP {status}")
+        # Remaining 4xx codes (400, 405-499 excluding 401/403/404)
+        # Use bounded read to avoid loading a large error body into memory
+        # (e.g. when called on a streaming response from download_with_hashes).
+        body_text = ""
+        try:
+            raw_bytes = response.raw.read(201) if response.raw else b""
+            if not raw_bytes:
+                raw_bytes = response.content[:201]
+            body_text = raw_bytes.decode("utf-8", errors="replace")[:200]
+            if len(raw_bytes) > 200:
+                body_text += " (truncated)"
+        except Exception:
+            pass
+        msg = f"Client error: HTTP {status}"
+        if body_text:
+            msg = f"{msg} — {body_text}"
+        raise TeaRequestError(msg)
diff --git a/src/libtea/_security.py b/src/libtea/_security.py
new file mode 100644
index 0000000..5d557a6
--- /dev/null
+++ b/src/libtea/_security.py
@@ -0,0 +1,91 @@
+"""SSRF protection for download URLs and discovery redirects.
+
+Validates that URLs target public networks only, blocking private/internal
+IPs, cloud metadata endpoints, and DNS-rebinding attempts. Used by both
+:mod:`libtea._http` (artifact downloads) and :mod:`libtea.discovery`
+(redirect validation).
+"""
+
+import ipaddress
+import logging
+import socket
+from urllib.parse import urlparse
+
+from libtea.exceptions import TeaValidationError
+
+logger = logging.getLogger("libtea")
+
+_BLOCKED_HOSTNAMES = frozenset(
+    {
+        "localhost",
+        "localhost.localdomain",
+        "metadata.google.internal",
+        "metadata.google.internal.",
+    }
+)
+
+# RFC 6598 CGNAT range — ipaddress.is_private misses this on Python 3.11+.
+_CGNAT_NETWORK = ipaddress.IPv4Network("100.64.0.0/10")
+
+
+def _is_internal_ip(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
+    """Return True if the IP address is non-global: private, loopback, link-local, reserved, unspecified, multicast, or CGNAT."""
+    if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
+        return True
+    if addr.is_unspecified or addr.is_multicast:
+        return True
+    # Extract embedded IPv4 from IPv4-mapped IPv6 (::ffff:x.x.x.x) before CGNAT check
+    check_v4 = addr
+    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
+        check_v4 = addr.ipv4_mapped
+    if isinstance(check_v4, ipaddress.IPv4Address) and check_v4 in _CGNAT_NETWORK:
+        return True
+    return False
+
+
+def _validate_resolved_ips(hostname: str) -> None:
+    """Resolve hostname via DNS and reject if any resolved IP is private/internal.
+
+    Note: There is an inherent TOCTOU (time-of-check-time-of-use) gap between
+    this DNS check and the actual HTTP request made by ``requests``.  A DNS
+    rebinding attack could return a safe IP here and a malicious IP for the
+    subsequent connection.  Fully closing this gap would require socket-level
+    IP pinning, which ``requests`` does not support.  This check still raises
+    the bar significantly against naive SSRF attempts.
+    """
+    try:
+        addr_infos = socket.getaddrinfo(hostname, None)
+    except socket.gaierror:
+        logger.warning("DNS resolution failed for %s during SSRF check; proceeding with request", hostname)
+        return
+    for _, _, _, _, sockaddr in addr_infos:
+        resolved_ip = sockaddr[0]
+        try:
+            addr = ipaddress.ip_address(resolved_ip)
+            if _is_internal_ip(addr):
+                raise TeaValidationError(
+                    f"Artifact download URL hostname {hostname!r} resolves to private/internal IP: {resolved_ip}"
+                )
+        except ValueError:
+            pass
+
+
+def _validate_download_url(url: str) -> None:
+    """Reject download URLs that use non-HTTP schemes or target internal networks."""
+    parsed = urlparse(url)
+    if parsed.scheme not in ("http", "https"):
+        raise TeaValidationError(f"Artifact download URL must use http or https scheme, got {parsed.scheme!r}")
+    if not parsed.hostname:
+        raise TeaValidationError(f"Artifact download URL must include a hostname: {url!r}")
+
+    hostname = parsed.hostname.lower()
+    if hostname in _BLOCKED_HOSTNAMES:
+        raise TeaValidationError(f"Artifact download URL must not target internal hosts: {hostname!r}")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+        if _is_internal_ip(addr):
+            raise TeaValidationError(f"Artifact download URL must not target private/internal IP: {hostname!r}")
+    except ValueError:
+        # Not an IP literal — resolve hostname and check resolved IPs (DNS rebinding protection)
+        _validate_resolved_ips(hostname)
diff --git a/src/libtea/_validation.py b/src/libtea/_validation.py
new file mode 100644
index 0000000..bd55860
--- /dev/null
+++ b/src/libtea/_validation.py
@@ -0,0 +1,82 @@
+"""Shared input-validation helpers used by TeaClient and (future) AsyncTeaClient.
+
+These are pure functions with no HTTP dependency, making them safe to import
+from any client implementation without pulling in the requests stack.
+"""
+
+import uuid as _uuid
+from typing import Any, TypeVar
+
+from pydantic import BaseModel, ValidationError
+
+from libtea.exceptions import TeaValidationError
+
+_M = TypeVar("_M", bound=BaseModel)
+
+
+def _validate(model_cls: type[_M], data: Any) -> _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.
+    """
+    try:
+        return model_cls.model_validate(data)
+    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]:
+    """Validate a JSON array where each element conforms to a Pydantic model.
+
+    Raises :class:`TeaValidationError` if ``data`` is not a list or any
+    element fails validation.
+    """
+    if not isinstance(data, list):
+        raise TeaValidationError(f"Expected list for {model_cls.__name__}, got {type(data).__name__}")
+    try:
+        return [model_cls.model_validate(item) for item in data]
+    except ValidationError as exc:
+        raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc
+
+
+def _validate_path_segment(value: str, name: str = "uuid") -> str:
+    """Validate that a value is a valid UUID per TEA spec (RFC 4122).
+
+    The TEA OpenAPI spec defines all path ``{uuid}`` parameters as
+    ``format: uuid`` with pattern ``^[0-9a-f]{8}-...-[0-9a-f]{12}$``.
+
+    Raises:
+        TeaValidationError: If the value is empty or not a valid UUID.
+    """
+    if not value:
+        raise TeaValidationError(f"Invalid {name}: must not be empty.")
+    try:
+        parsed = _uuid.UUID(value)
+    except ValueError as exc:
+        raise TeaValidationError(
+            f"Invalid {name}: {value!r}. Must be a valid UUID (e.g. 'd4d9f54a-abcf-11ee-ac79-1a52914d44b1')."
+        ) from exc
+    return str(parsed)
+
+
+_MAX_PAGE_SIZE = 10000
+
+
+def _validate_page_size(page_size: int) -> 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}")
+
+
+def _validate_collection_version(version: int) -> None:
+    """Validate that a collection version number is >= 1 per spec."""
+    if version < 1:
+        raise TeaValidationError(f"Collection version must be >= 1, got {version}")
diff --git a/src/libtea/cli.py b/src/libtea/cli.py
new file mode 100644
index 0000000..50edeae
--- /dev/null
+++ b/src/libtea/cli.py
@@ -0,0 +1,659 @@
+"""CLI for the Transparency Exchange API.
+
+Provides the ``tea-cli`` command backed by typer. Each subcommand maps
+to a :class:`~libtea.client.TeaClient` method and outputs rich-formatted
+tables and panels by default (or JSON when ``--json`` is specified).
+All commands accept ``--base-url`` / ``--domain`` for server selection,
+and ``--token`` / ``--auth`` / ``--client-cert`` for authentication.
+"""
+
+import json
+import logging
+import sys
+from pathlib import Path
+from typing import Annotated, Any, NoReturn
+
+import typer
+from pydantic import BaseModel
+
+from libtea._http import MtlsConfig
+from libtea.client import TEA_SPEC_VERSION, TeaClient
+from libtea.discovery import parse_tei
+from libtea.exceptions import TeaDiscoveryError, TeaError
+from libtea.models import (
+    Checksum,
+    ChecksumAlgorithm,
+    ComponentReleaseWithCollection,
+    ProductRelease,
+    normalize_algorithm_name,
+)
+
+logger = logging.getLogger("libtea")
+
+app = typer.Typer(help="TEA (Transparency Exchange API) CLI client.", no_args_is_help=True)
+
+_json_output: bool = False
+
+# --- Shared options ---
+
+_base_url_opt = typer.Option(envvar="TEA_BASE_URL", help="TEA server base URL")
+_token_opt = typer.Option(
+    envvar="TEA_TOKEN", help="Bearer token (prefer TEA_TOKEN env var to avoid shell history exposure)"
+)
+_auth_opt = typer.Option(envvar="TEA_AUTH", help="Basic auth as USER:PASSWORD (prefer TEA_AUTH env var)")
+_domain_opt = typer.Option(help="Discover server from domain's .well-known/tea")
+_timeout_opt = typer.Option(help="Request timeout in seconds")
+_use_http_opt = typer.Option(help="Use HTTP instead of HTTPS for discovery")
+_port_opt = typer.Option(help="Port for well-known resolution")
+_client_cert_opt = typer.Option(help="Path to client certificate for mTLS")
+_client_key_opt = typer.Option(help="Path to client private key for mTLS")
+_ca_bundle_opt = typer.Option(help="Path to CA bundle for mTLS server verification")
+
+
+def _parse_basic_auth(auth: str | None) -> tuple[str, str] | None:
+    """Parse a ``USER:PASSWORD`` string into a ``(user, password)`` tuple.
+
+    Returns ``None`` if ``auth`` is ``None`` or empty. Calls :func:`_error`
+    (which exits) if the format is invalid.
+    """
+    if not auth:
+        return None
+    if ":" not in auth:
+        _error("Invalid --auth format. Expected USER:PASSWORD")
+    user, password = auth.split(":", 1)
+    return (user, password)
+
+
+def _build_mtls(client_cert: str | None, client_key: str | None, ca_bundle: str | None) -> MtlsConfig | None:
+    """Build an :class:`~libtea.MtlsConfig` from CLI options, or return ``None``.
+
+    Both ``--client-cert`` and ``--client-key`` must be provided together.
+    Calls :func:`_error` if only one is specified.
+    """
+    if not client_cert and not client_key:
+        return None
+    if client_cert and not client_key:
+        _error("--client-key is required when --client-cert is specified")
+    if client_key and not client_cert:
+        _error("--client-cert is required when --client-key is specified")
+    assert client_cert is not None
+    assert client_key is not None
+    return MtlsConfig(
+        client_cert=Path(client_cert),
+        client_key=Path(client_key),
+        ca_bundle=Path(ca_bundle) if ca_bundle else None,
+    )
+
+
+def _domain_from_tei(tei: str | None) -> str | None:
+    """Extract the domain component from a TEI URN for auto-discovery.
+
+    Returns ``None`` if ``tei`` is falsy or not a valid TEI URN.
+    """
+    if not tei:
+        return None
+    try:
+        _, domain, _ = parse_tei(tei)
+        return domain
+    except TeaDiscoveryError:
+        return None
+
+
+def _build_client(
+    base_url: str | None,
+    token: str | None,
+    domain: str | None,
+    timeout: float,
+    use_http: bool,
+    port: int | None,
+    auth: str | None = None,
+    client_cert: str | None = None,
+    client_key: str | None = None,
+    ca_bundle: str | None = None,
+    tei: str | None = None,
+) -> TeaClient:
+    """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.
+    """
+    if base_url and domain:
+        _error("Cannot use both --base-url and --domain")
+    if not base_url and not domain:
+        domain = _domain_from_tei(tei)
+    if not base_url and not domain:
+        _error("Must specify either --base-url or --domain (or provide a TEI to auto-discover)")
+    basic_auth = _parse_basic_auth(auth)
+    mtls = _build_mtls(client_cert, client_key, ca_bundle)
+    if base_url:
+        return TeaClient(base_url=base_url, token=token, basic_auth=basic_auth, timeout=timeout, mtls=mtls)
+    assert domain is not None
+    scheme = "http" if use_http else "https"
+    return TeaClient.from_well_known(
+        domain, token=token, basic_auth=basic_auth, timeout=timeout, scheme=scheme, port=port, mtls=mtls
+    )
+
+
+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.
+    """
+    if _json_output:
+        if isinstance(data, BaseModel):
+            data = data.model_dump(mode="json", by_alias=True)
+        elif isinstance(data, list):
+            data = [
+                item.model_dump(mode="json", by_alias=True) if isinstance(item, BaseModel) else item for item in data
+            ]
+        json.dump(data, sys.stdout, indent=2, default=str)
+        print()
+    else:
+        from libtea._cli_fmt import format_output
+
+        format_output(data, command=command)
+
+
+def _error(message: str) -> NoReturn:
+    """Print an error message to stderr and exit with code 1."""
+    print(f"Error: {message}", file=sys.stderr)
+    raise typer.Exit(1)
+
+
+# --- Commands ---
+
+
+@app.command()
+def discover(
+    tei: str,
+    quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Output only UUIDs, one per line")] = False,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Resolve a TEI to product release UUID(s)."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle, tei=tei
+        ) as client:
+            result = client.discover(tei)
+        if quiet:
+            for d in result:
+                print(d.product_release_uuid)
+        else:
+            _output(result, command="discover")
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("search-products")
+def search_products(
+    id_type: Annotated[str, typer.Option("--id-type", help="Identifier type (CPE, TEI, PURL)")],
+    id_value: Annotated[str, typer.Option("--id-value", help="Identifier value")],
+    page_offset: Annotated[int, typer.Option("--page-offset", help="Page offset")] = 0,
+    page_size: Annotated[int, typer.Option("--page-size", help="Page size")] = 100,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Search for products by identifier."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.search_products(id_type, id_value, page_offset=page_offset, page_size=page_size)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("search-releases")
+def search_releases(
+    id_type: Annotated[str, typer.Option("--id-type", help="Identifier type (CPE, TEI, PURL)")],
+    id_value: Annotated[str, typer.Option("--id-value", help="Identifier value")],
+    page_offset: Annotated[int, typer.Option("--page-offset", help="Page offset")] = 0,
+    page_size: Annotated[int, typer.Option("--page-size", help="Page size")] = 100,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Search for product releases by identifier."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.search_product_releases(id_type, id_value, page_offset=page_offset, page_size=page_size)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-product")
+def get_product(
+    uuid: str,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get a product by UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.get_product(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-release")
+def get_release(
+    uuid: str,
+    component: Annotated[
+        bool, typer.Option("--component", help="Get a component release instead of product release")
+    ] = False,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get a product or component release by UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result: ProductRelease | ComponentReleaseWithCollection
+            if component:
+                result = client.get_component_release(uuid)
+            else:
+                result = client.get_product_release(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-collection")
+def get_collection(
+    uuid: str,
+    version: Annotated[int | None, typer.Option("--version", help="Collection version (default: latest)")] = None,
+    component: Annotated[
+        bool, typer.Option("--component", help="Get from component release instead of product release")
+    ] = False,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get a collection (latest or by version)."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            if component:
+                if version is not None:
+                    result = client.get_component_release_collection(uuid, version)
+                else:
+                    result = client.get_component_release_collection_latest(uuid)
+            else:
+                if version is not None:
+                    result = client.get_product_release_collection(uuid, version)
+                else:
+                    result = client.get_product_release_collection_latest(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-product-releases")
+def get_product_releases(
+    uuid: str,
+    page_offset: Annotated[int, typer.Option("--page-offset", help="Page offset")] = 0,
+    page_size: Annotated[int, typer.Option("--page-size", help="Page size")] = 100,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """List releases for a product UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.get_product_releases(uuid, page_offset=page_offset, page_size=page_size)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-component")
+def get_component(
+    uuid: str,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get a component by UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.get_component(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-component-releases")
+def get_component_releases(
+    uuid: str,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """List releases for a component UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.get_component_releases(uuid)
+        _output(result, command="releases")
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("list-collections")
+def list_collections(
+    uuid: str,
+    component: Annotated[
+        bool, typer.Option("--component", help="List collections for a component release instead of product release")
+    ] = False,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """List all collection versions for a release UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            if component:
+                result = client.get_component_release_collections(uuid)
+            else:
+                result = client.get_product_release_collections(uuid)
+        _output(result, command="collections")
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-cle")
+def get_cle(
+    uuid: str,
+    entity: Annotated[
+        str,
+        typer.Option(
+            "--entity",
+            help="Entity type: product, product-release, component, or component-release",
+        ),
+    ] = "product-release",
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get Common Lifecycle Enumeration (CLE) for an entity."""
+    entity_methods = {
+        "product": "get_product_cle",
+        "product-release": "get_product_release_cle",
+        "component": "get_component_cle",
+        "component-release": "get_component_release_cle",
+    }
+    if entity not in entity_methods:
+        _error(f"Invalid --entity: {entity!r}. Must be one of: {', '.join(entity_methods)}")
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = getattr(client, entity_methods[entity])(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command("get-artifact")
+def get_artifact(
+    uuid: str,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Get artifact metadata by UUID."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.get_artifact(uuid)
+        _output(result)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command()
+def download(
+    url: str,
+    dest: Path,
+    checksum: Annotated[list[str] | None, typer.Option("--checksum", help="Checksum as ALG:VALUE (repeatable)")] = None,
+    max_download_bytes: Annotated[
+        int | None, typer.Option("--max-download-bytes", help="Maximum download size in bytes")
+    ] = None,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Download an artifact file with optional checksum verification."""
+    checksums = None
+    if checksum:
+        checksums = []
+        for cs in checksum:
+            if ":" not in cs:
+                _error(f"Invalid checksum format: {cs!r}. Expected ALG:VALUE (e.g. SHA-256:abcdef...)")
+            alg, value = cs.split(":", 1)
+            # Normalize underscore form (SHA_256) to hyphen form (SHA-256)
+            alg = normalize_algorithm_name(alg)
+            try:
+                alg_enum = ChecksumAlgorithm(alg)
+            except ValueError:
+                _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))
+
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle
+        ) as client:
+            result = client.download_artifact(
+                url, dest, verify_checksums=checksums, max_download_bytes=max_download_bytes
+            )
+        print(f"Downloaded to {result}", file=sys.stderr)
+    except TeaError as exc:
+        _error(str(exc))
+
+
+@app.command()
+def inspect(
+    tei: str,
+    max_components: Annotated[
+        int, typer.Option("--max-components", min=1, help="Maximum number of components to fetch per release")
+    ] = 50,
+    base_url: Annotated[str | None, _base_url_opt] = None,
+    token: Annotated[str | None, _token_opt] = None,
+    auth: Annotated[str | None, _auth_opt] = None,
+    domain: Annotated[str | None, _domain_opt] = None,
+    timeout: Annotated[float, _timeout_opt] = 30.0,
+    use_http: Annotated[bool, _use_http_opt] = False,
+    port: Annotated[int | None, _port_opt] = None,
+    client_cert: Annotated[str | None, _client_cert_opt] = None,
+    client_key: Annotated[str | None, _client_key_opt] = None,
+    ca_bundle: Annotated[str | None, _ca_bundle_opt] = None,
+) -> None:
+    """Full flow: TEI -> discovery -> releases -> artifacts."""
+    try:
+        with _build_client(
+            base_url, token, domain, timeout, use_http, port, auth, client_cert, client_key, ca_bundle, tei=tei
+        ) as client:
+            discoveries = client.discover(tei)
+            result = []
+            for disc in discoveries:
+                pr = client.get_product_release(disc.product_release_uuid)
+                components = []
+                for comp_ref in pr.components[:max_components]:
+                    if comp_ref.release:
+                        cr = client.get_component_release(comp_ref.release)
+                        components.append(cr.model_dump(mode="json", by_alias=True))
+                    else:
+                        # Unpinned component — resolve latest release like rearm does
+                        comp = client.get_component(comp_ref.uuid)
+                        comp_data = comp.model_dump(mode="json", by_alias=True)
+                        try:
+                            releases = client.get_component_releases(comp_ref.uuid)
+                            if releases:
+                                latest = releases[0]
+                                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)"
+                        except TeaError as exc:
+                            logger.debug("Could not resolve releases for component %s: %s", comp_ref.uuid, exc)
+                        components.append(comp_data)
+                truncated = len(pr.components) > max_components
+                entry: dict[str, Any] = {
+                    "discovery": disc.model_dump(mode="json", by_alias=True),
+                    "productRelease": pr.model_dump(mode="json", by_alias=True),
+                    "components": components,
+                }
+                if truncated:
+                    entry["truncated"] = True
+                    entry["totalComponents"] = len(pr.components)
+                    print(
+                        f"Warning: truncated {len(pr.components)} components to {max_components} "
+                        f"(use --max-components to increase)",
+                        file=sys.stderr,
+                    )
+                result.append(entry)
+            _output(result, command="inspect")
+    except TeaError as exc:
+        _error(str(exc))
+
+
+def _version_callback(value: bool) -> None:
+    """Eager callback for ``--version`` that prints version info and exits."""
+    if value:
+        from libtea import __version__
+
+        print(f"tea-cli {__version__} (TEA spec {TEA_SPEC_VERSION})")
+        raise typer.Exit()
+
+
+@app.callback()
+def main(
+    version: Annotated[
+        bool | None, typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version")
+    ] = None,
+    output_json: Annotated[
+        bool, typer.Option("--json", help="Output raw JSON instead of rich-formatted tables")
+    ] = False,
+    debug: Annotated[bool, typer.Option("--debug", "-d", help="Show debug output (HTTP requests, timing)")] = False,
+) -> None:
+    """TEA (Transparency Exchange API) CLI client."""
+    global _json_output  # noqa: PLW0603
+    _json_output = output_json
+    if debug:
+        logging.basicConfig(format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr)
+        logging.getLogger("libtea").setLevel(logging.DEBUG)
diff --git a/libtea/client.py b/src/libtea/client.py
similarity index 56%
rename from libtea/client.py
rename to src/libtea/client.py
index 066d25c..c8f0ce2 100644
--- a/libtea/client.py
+++ b/src/libtea/client.py
@@ -1,18 +1,35 @@
-"""TeaClient - main entry point for the TEA consumer API."""
+"""TeaClient — main entry point for the TEA consumer (read-only) API.
+
+Provides high-level methods for discovery, product/component lookup,
+collection retrieval, CLE queries, and artifact download with checksum
+verification. All HTTP is delegated to :class:`~libtea._http.TeaHttpClient`.
+"""
 
 import hmac
 import logging
-import re
+import warnings
 from pathlib import Path
 from types import TracebackType
-from typing import Any, Self, TypeVar
-
-from pydantic import BaseModel, ValidationError
-
-from libtea._http import TeaHttpClient
-from libtea.discovery import fetch_well_known, select_endpoint
-from libtea.exceptions import TeaChecksumError, TeaValidationError
+from typing import Self
+
+from libtea._http import MtlsConfig, TeaHttpClient, probe_endpoint
+from libtea._validation import (
+    _validate,
+    _validate_collection_version,
+    _validate_list,
+    _validate_page_offset,
+    _validate_page_size,
+    _validate_path_segment,
+)
+from libtea.discovery import fetch_well_known, select_endpoints
+from libtea.exceptions import (
+    TeaChecksumError,
+    TeaConnectionError,
+    TeaDiscoveryError,
+    TeaServerError,
+)
 from libtea.models import (
+    CLE,
     Artifact,
     Checksum,
     Collection,
@@ -30,46 +47,28 @@
 
 TEA_SPEC_VERSION = "0.3.0-beta.2"
 
-_M = TypeVar("_M", bound=BaseModel)
-
-# Restrict URL path segments to safe characters to prevent path traversal and injection.
-_SAFE_PATH_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9\-]{1,128}$")
-
-
-def _validate(model_cls: type[_M], data: Any) -> _M:
-    """Validate data against a Pydantic model, wrapping errors in TeaValidationError."""
-    try:
-        return model_cls.model_validate(data)
-    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]:
-    """Validate a list of items against a Pydantic model."""
-    if not isinstance(data, list):
-        raise TeaValidationError(f"Expected list for {model_cls.__name__}, got {type(data).__name__}")
-    try:
-        return [model_cls.model_validate(item) for item in data]
-    except ValidationError as exc:
-        raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc
+_WEAK_HASH_ALGORITHMS = frozenset({"MD5", "SHA-1"})
 
 
-def _validate_path_segment(value: str, name: str = "uuid") -> str:
-    """Validate that a value is safe to interpolate into a URL path."""
-    if not _SAFE_PATH_SEGMENT_RE.match(value):
-        raise TeaValidationError(
-            f"Invalid {name}: {value!r}. Must contain only alphanumeric characters and hyphens, max 128 characters."
-        )
-    return value
+class TeaClient:
+    """Synchronous client for the Transparency Exchange API (consumer / read-only).
 
+    Supports context-manager usage for automatic resource cleanup::
 
-class TeaClient:
-    """Synchronous client for the Transparency Exchange API.
+        with TeaClient("https://tea.example.com/v1", token="...") as client:
+            product = client.get_product(uuid)
 
     Args:
         base_url: TEA server base URL (e.g. ``https://tea.example.com/v1``).
-        token: Optional bearer token for authentication.
-        timeout: Request timeout in seconds.
+        token: Optional bearer token for authentication. Mutually exclusive
+            with ``basic_auth``. Rejected with plaintext HTTP.
+        basic_auth: Optional ``(username, password)`` tuple for HTTP Basic auth.
+            Mutually exclusive with ``token``. Rejected with plaintext HTTP.
+        timeout: Request timeout in seconds (default 30).
+        mtls: Optional :class:`~libtea.MtlsConfig` for mutual TLS authentication.
+        max_retries: Number of automatic retries on 5xx responses (default 3).
+        backoff_factor: Exponential backoff multiplier between retries (default 0.5).
     """
 
     def __init__(
@@ -77,9 +76,21 @@ def __init__(
         base_url: str,
         *,
         token: str | None = None,
+        basic_auth: tuple[str, str] | None = None,
         timeout: float = 30.0,
+        mtls: MtlsConfig | None = None,
+        max_retries: int = 3,
+        backoff_factor: float = 0.5,
     ):
-        self._http = TeaHttpClient(base_url=base_url, token=token, timeout=timeout)
+        self._http = TeaHttpClient(
+            base_url=base_url,
+            token=token,
+            basic_auth=basic_auth,
+            timeout=timeout,
+            mtls=mtls,
+            max_retries=max_retries,
+            backoff_factor=backoff_factor,
+        )
 
     @classmethod
     def from_well_known(
@@ -87,14 +98,69 @@ def from_well_known(
         domain: str,
         *,
         token: str | None = None,
+        basic_auth: tuple[str, str] | None = None,
         timeout: float = 30.0,
         version: str = TEA_SPEC_VERSION,
+        scheme: str = "https",
+        port: int | None = None,
+        mtls: MtlsConfig | None = None,
+        max_retries: int = 3,
+        backoff_factor: float = 0.5,
     ) -> Self:
-        """Create a client by discovering the TEA endpoint from a domain's .well-known/tea."""
-        well_known = fetch_well_known(domain, timeout=timeout)
-        endpoint = select_endpoint(well_known, version)
-        base_url = f"{endpoint.url.rstrip('/')}/v{version}"
-        return cls(base_url=base_url, token=token, timeout=timeout)
+        """Create a client by discovering the TEA endpoint from a domain's .well-known/tea.
+
+        Fetches the ``.well-known/tea`` document, selects all endpoints compatible
+        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").
+
+        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: library's built-in version).
+            scheme: URL scheme for discovery — ``"https"`` (default) or ``"http"``.
+            port: Optional port for ``.well-known`` resolution.
+            mtls: Optional :class:`~libtea.MtlsConfig`.
+            max_retries: Retry count on 5xx (default 3).
+            backoff_factor: Backoff multiplier (default 0.5).
+
+        Returns:
+            A connected :class:`TeaClient` pointing at the best reachable endpoint.
+
+        Raises:
+            TeaDiscoveryError: If no compatible or reachable endpoint is found
+                (wraps the last probe failure as ``__cause__``).
+        """
+        well_known = fetch_well_known(domain, timeout=timeout, scheme=scheme, port=port, mtls=mtls)
+        candidates = select_endpoints(well_known, version)
+
+        errors: list[tuple[str, Exception]] = []
+        for endpoint in candidates:
+            base_url = f"{endpoint.url.rstrip('/')}/v{version}"
+            try:
+                probe_endpoint(base_url, timeout=min(timeout, 5.0), mtls=mtls)
+            except (TeaConnectionError, TeaServerError) as exc:
+                logger.warning("Endpoint %s unreachable, trying next: %s", base_url, exc)
+                errors.append((base_url, exc))
+                continue
+            return cls(
+                base_url=base_url,
+                token=token,
+                basic_auth=basic_auth,
+                timeout=timeout,
+                mtls=mtls,
+                max_retries=max_retries,
+                backoff_factor=backoff_factor,
+            )
+
+        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}"
+            ) from errors[-1][1]
+        raise TeaDiscoveryError(f"No reachable endpoint found for version {version!r}")  # pragma: no cover
 
     # --- Discovery ---
 
@@ -120,7 +186,19 @@ def discover(self, tei: str) -> list[DiscoveryInfo]:
     def search_products(
         self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100
     ) -> PaginatedProductResponse:
-        """Search for products by identifier (e.g. PURL, CPE, TEI)."""
+        """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).
+
+        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},
@@ -152,6 +230,8 @@ def get_product_releases(
         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},
@@ -163,7 +243,19 @@ def get_product_releases(
     def search_product_releases(
         self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100
     ) -> PaginatedProductReleaseResponse:
-        """Search for product releases by identifier (e.g. PURL, CPE, TEI)."""
+        """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).
+
+        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},
@@ -216,6 +308,7 @@ def get_product_release_collection(self, uuid: str, version: int) -> Collection:
         Returns:
             The requested collection version.
         """
+        _validate_collection_version(version)
         data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collection/{version}")
         return _validate(Collection, data)
 
@@ -295,9 +388,60 @@ def get_component_release_collection(self, uuid: str, version: int) -> Collectio
         Returns:
             The requested collection version.
         """
+        _validate_collection_version(version)
         data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collection/{version}")
         return _validate(Collection, data)
 
+    # --- CLE ---
+
+    def get_product_cle(self, uuid: str) -> CLE:
+        """Get CLE (Common Lifecycle Enumeration) data for a product.
+
+        Args:
+            uuid: Product UUID.
+
+        Returns:
+            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)
+
+    def get_product_release_cle(self, uuid: str) -> CLE:
+        """Get CLE data for a product release.
+
+        Args:
+            uuid: Product release UUID.
+
+        Returns:
+            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)
+
+    def get_component_cle(self, uuid: str) -> CLE:
+        """Get CLE data for a component.
+
+        Args:
+            uuid: Component UUID.
+
+        Returns:
+            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)
+
+    def get_component_release_cle(self, uuid: str) -> CLE:
+        """Get CLE data for a component release.
+
+        Args:
+            uuid: Component release UUID.
+
+        Returns:
+            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)
+
     # --- Artifacts ---
 
     def get_artifact(self, uuid: str) -> Artifact:
@@ -318,6 +462,7 @@ def download_artifact(
         dest: Path,
         *,
         verify_checksums: list[Checksum] | None = None,
+        max_download_bytes: int | None = None,
     ) -> Path:
         """Download an artifact file, optionally verifying checksums.
 
@@ -329,6 +474,7 @@ def download_artifact(
             dest: Local file path to write to.
             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.
@@ -336,9 +482,19 @@ def download_artifact(
         Raises:
             TeaChecksumError: If checksum verification fails.
             TeaConnectionError: On network failure.
+            TeaValidationError: If download exceeds max_download_bytes.
         """
+        if verify_checksums:
+            weak = {cs.algorithm_type.value for cs in verify_checksums} & _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 verify_checksums else None
-        computed = self._http.download_with_hashes(url, dest, algorithms=algorithms)
+        computed = self._http.download_with_hashes(
+            url, dest, algorithms=algorithms, max_download_bytes=max_download_bytes
+        )
 
         if verify_checksums:
             self._verify_checksums(verify_checksums, computed, url, dest)
@@ -347,7 +503,14 @@ def download_artifact(
 
     @staticmethod
     def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: str, dest: Path) -> None:
-        """Verify computed checksums against expected values, cleaning up on failure."""
+        """Verify computed checksums against expected values, cleaning up on failure.
+
+        Uses :func:`hmac.compare_digest` for constant-time comparison.
+        Deletes the downloaded file at ``dest`` on the first mismatch.
+
+        Raises:
+            TeaChecksumError: If any checksum does not match.
+        """
         for cs in checksums:
             alg_name = cs.algorithm_type.value
             expected = cs.algorithm_value.lower()
@@ -379,6 +542,7 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url:
     # --- Lifecycle ---
 
     def close(self) -> None:
+        """Close the underlying HTTP session and clear credentials."""
         self._http.close()
 
     def __enter__(self) -> Self:
diff --git a/src/libtea/discovery.py b/src/libtea/discovery.py
new file mode 100644
index 0000000..645bb59
--- /dev/null
+++ b/src/libtea/discovery.py
@@ -0,0 +1,269 @@
+"""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``
+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 warnings
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import requests
+from pydantic import ValidationError
+from semver import Version as _SemVer
+
+from libtea._http import USER_AGENT, MtlsConfig
+from libtea._security import _validate_download_url
+from libtea.exceptions import TeaDiscoveryError, TeaInsecureTransportWarning, TeaValidationError
+from libtea.models import TeaEndpoint, TeaWellKnown, TeiType
+
+logger = logging.getLogger("libtea")
+
+_VALID_TEI_TYPES = frozenset(e.value for e in TeiType)
+_DOMAIN_LABEL_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-")
+
+
+def _is_valid_domain(domain: str) -> bool:
+    """Validate a domain name per RFC 952 / RFC 1123.
+
+    Rules: each label is 1-63 characters of ``[a-zA-Z0-9-]`` with no leading
+    or trailing hyphens, and the total length is at most 253 characters.
+    """
+    if not domain or len(domain) > 253:
+        return False
+    for label in domain.split("."):
+        if not label or len(label) > 63:
+            return False
+        if label[0] == "-" or label[-1] == "-":
+            return False
+        if not all(c in _DOMAIN_LABEL_CHARS for c in label):
+            return False
+    return True
+
+
+def parse_tei(tei: str) -> tuple[str, str, str]:
+    """Parse a TEI URN into (type, domain, identifier).
+
+    TEI format: ``urn:tei:::``
+
+    Args:
+        tei: TEI URN string.
+
+    Returns:
+        Tuple of (type, domain, identifier).
+
+    Raises:
+        TeaDiscoveryError: If the TEI format is invalid.
+    """
+    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:::")
+
+    tei_type = parts[2]
+    if tei_type not in _VALID_TEI_TYPES:
+        raise TeaDiscoveryError(
+            f"Invalid TEI type: {tei_type!r}. Must be one of: {', '.join(sorted(_VALID_TEI_TYPES))}"
+        )
+    domain = parts[3]
+    if not domain or not _is_valid_domain(domain):
+        raise TeaDiscoveryError(f"Invalid domain in TEI: {domain!r}")
+    identifier = ":".join(parts[4:])
+    return tei_type, domain, identifier
+
+
+def fetch_well_known(
+    domain: str,
+    *,
+    timeout: float = 10.0,
+    scheme: str = "https",
+    port: int | None = None,
+    mtls: MtlsConfig | None = None,
+) -> TeaWellKnown:
+    """Fetch and parse the .well-known/tea discovery document from a domain.
+
+    Args:
+        domain: Domain name to resolve (e.g. ``tea.example.com``).
+        timeout: HTTP request timeout in seconds.
+        scheme: URL scheme, ``"https"`` (default) or ``"http"``.
+        port: Optional port number. Default ports (443 for https, 80 for http)
+            are omitted from the URL.
+        mtls: Optional mutual TLS configuration.
+
+    Returns:
+        Parsed well-known document with endpoint list.
+
+    Raises:
+        TeaDiscoveryError: If the domain is invalid, unreachable, or returns
+            an invalid document.
+    """
+    if scheme not in ("http", "https"):
+        raise TeaDiscoveryError(f"Invalid scheme: {scheme!r}. Must be 'http' or 'https'.")
+    if scheme == "http":
+        warnings.warn(
+            "Fetching .well-known/tea over plaintext HTTP. Use HTTPS in production.",
+            TeaInsecureTransportWarning,
+            stacklevel=2,
+        )
+    if port is not None and not (1 <= port <= 65535):
+        raise TeaDiscoveryError(f"Invalid port: {port}. Must be between 1 and 65535.")
+    if not domain or not _is_valid_domain(domain):
+        raise TeaDiscoveryError(f"Invalid domain: {domain!r}")
+
+    default_port = 80 if scheme == "http" else 443
+    resolved_port = port if port is not None else default_port
+    if resolved_port == default_port:
+        url = f"{scheme}://{domain}/.well-known/tea"
+    else:
+        url = f"{scheme}://{domain}:{resolved_port}/.well-known/tea"
+
+    kwargs: dict[str, Any] = {"timeout": timeout, "allow_redirects": False, "headers": {"user-agent": USER_AGENT}}
+    if mtls:
+        kwargs["cert"] = (str(mtls.client_cert), str(mtls.client_key))
+        if mtls.ca_bundle:
+            kwargs["verify"] = str(mtls.ca_bundle)
+
+    logger.debug("Fetching well-known discovery document: %s", url)
+    _max_discovery_redirects = 5
+    current_url = url
+    try:
+        # Follow redirects manually with SSRF validation at each hop
+        # (automatic redirects would allow intermediate hops to internal IPs).
+        response = None
+        redirects = 0
+        try:
+            while True:
+                response = requests.get(current_url, **kwargs)
+                if 300 <= response.status_code < 400:
+                    redirects += 1
+                    if redirects > _max_discovery_redirects:
+                        raise TeaDiscoveryError(f"Too many discovery redirects (max {_max_discovery_redirects})")
+                    location = response.headers.get("Location")
+                    if not location:
+                        raise TeaDiscoveryError(
+                            f"Discovery redirect without Location header: HTTP {response.status_code}"
+                        )
+                    current_url = urljoin(current_url, location)
+                    # Validate scheme and SSRF at each hop
+                    hop_parsed = urlparse(current_url)
+                    if hop_parsed.scheme not in ("http", "https"):
+                        raise TeaDiscoveryError(f"Discovery redirected to unsupported scheme: {hop_parsed.scheme!r}")
+                    if scheme == "https" and hop_parsed.scheme == "http":
+                        warnings.warn(
+                            f"Discovery for {domain} was downgraded from HTTPS to HTTP via redirect. "
+                            "This may indicate a misconfigured server.",
+                            TeaInsecureTransportWarning,
+                            stacklevel=2,
+                        )
+                    try:
+                        _validate_download_url(current_url)
+                    except TeaValidationError as exc:
+                        raise TeaDiscoveryError(f"Discovery for {domain} redirected to blocked target: {exc}") from exc
+                    response.close()
+                    response = None
+                    continue
+                break
+
+            if response.status_code >= 400:
+                body_snippet = (response.text or "")[:200]
+                if len(response.text or "") > 200:
+                    body_snippet += " (truncated)"
+                msg = f"Failed to fetch {current_url}: HTTP {response.status_code}"
+                if body_snippet:
+                    msg = f"{msg} — {body_snippet}"
+                raise TeaDiscoveryError(msg)
+
+            try:
+                data = response.json()
+            except ValueError as exc:
+                raise TeaDiscoveryError(f"Invalid JSON in .well-known/tea response from {domain}") from exc
+        finally:
+            if response is not None:
+                response.close()
+    except requests.ConnectionError as exc:
+        logger.warning("Discovery connection error for %s: %s", current_url, exc)
+        raise TeaDiscoveryError(f"Failed to connect to {current_url}: {exc}") from exc
+    except requests.Timeout as exc:
+        logger.warning("Discovery timeout for %s: %s", current_url, exc)
+        raise TeaDiscoveryError(f"Failed to connect to {current_url}: {exc}") from exc
+    except requests.RequestException as exc:
+        raise TeaDiscoveryError(f"HTTP error fetching {current_url}: {exc}") from exc
+
+    try:
+        return TeaWellKnown.model_validate(data)
+    except ValidationError as exc:
+        raise TeaDiscoveryError(f"Invalid .well-known/tea document from {domain}: {exc}") from exc
+
+
+def select_endpoints(well_known: TeaWellKnown, supported_version: str) -> list[TeaEndpoint]:
+    """Select all endpoints that support the given version, sorted by priority.
+
+    Per TEA spec: uses SemVer 2.0.0 comparison to match versions, then
+    sorts by highest matching version with priority as tiebreaker.
+
+    Args:
+        well_known: Parsed .well-known/tea document.
+        supported_version: SemVer version string the client supports.
+
+    Returns:
+        List of matching endpoints, best first.
+
+    Raises:
+        TeaDiscoveryError: If no endpoint supports the requested version.
+    """
+    try:
+        target = _SemVer.parse(supported_version)
+    except ValueError as exc:
+        raise TeaDiscoveryError(f"Invalid version string {supported_version!r}: {exc}") from exc
+
+    candidates: list[tuple[_SemVer, TeaEndpoint]] = []
+    for ep in well_known.endpoints:
+        for v_str in ep.versions:
+            try:
+                v = _SemVer.parse(v_str)
+            except ValueError:
+                continue
+            if v == target:
+                candidates.append((v, ep))
+                break
+
+    if not candidates:
+        available = {v for ep in well_known.endpoints for v in ep.versions}
+        raise TeaDiscoveryError(
+            f"No compatible endpoint found for version {supported_version!r}. Available versions: {sorted(available)}"
+        )
+
+    # Sort by: highest SemVer version desc, then priority desc (default 1.0 per spec)
+    candidates.sort(
+        key=lambda pair: (pair[0], pair[1].priority if pair[1].priority is not None else 1.0),
+        reverse=True,
+    )
+    return [ep for _, ep in candidates]
+
+
+def select_endpoint(well_known: TeaWellKnown, supported_version: str) -> TeaEndpoint:
+    """Select the best endpoint that supports the given version.
+
+    Convenience wrapper around :func:`select_endpoints` that returns only
+    the top-priority candidate.
+
+    Args:
+        well_known: Parsed .well-known/tea document.
+        supported_version: SemVer version string the client supports.
+
+    Returns:
+        The best matching endpoint.
+
+    Raises:
+        TeaDiscoveryError: If no endpoint supports the requested version.
+    """
+    return select_endpoints(well_known, supported_version)[0]
+
+
+__all__ = [
+    "fetch_well_known",
+    "parse_tei",
+    "select_endpoint",
+    "select_endpoints",
+]
diff --git a/src/libtea/exceptions.py b/src/libtea/exceptions.py
new file mode 100644
index 0000000..8ac4564
--- /dev/null
+++ b/src/libtea/exceptions.py
@@ -0,0 +1,97 @@
+"""Exception hierarchy for the TEA client library.
+
+All library-specific exceptions inherit from :class:`TeaError`, making it
+easy to catch any TEA-related failure with a single ``except TeaError`` clause.
+:class:`TeaInsecureTransportWarning` is a :class:`UserWarning` (not an exception)
+emitted when plaintext HTTP is used instead of HTTPS.
+"""
+
+
+class TeaError(Exception):
+    """Base exception for all TEA client errors.
+
+    Catch this to handle any error raised by the library.
+    """
+
+
+class TeaConnectionError(TeaError):
+    """Network or connection failure (DNS, TCP, TLS, timeout)."""
+
+
+class TeaAuthenticationError(TeaError):
+    """HTTP 401 (Unauthorized) or 403 (Forbidden) response from the TEA server."""
+
+
+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"``).
+    """
+
+    def __init__(self, message: str, *, error_type: str | None = None):
+        super().__init__(message)
+        self.error_type = error_type
+
+
+class TeaRequestError(TeaError):
+    """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404)."""
+
+
+class TeaServerError(TeaError):
+    """HTTP 5xx response indicating a server-side failure."""
+
+
+class TeaDiscoveryError(TeaError):
+    """Discovery-specific failure: invalid TEI, unreachable .well-known, or no compatible endpoint."""
+
+
+class TeaChecksumError(TeaError):
+    """Checksum verification failure on artifact download.
+
+    Attributes:
+        algorithm: Checksum algorithm name (e.g. ``"SHA-256"``), or ``None``
+            if the failure is not algorithm-specific.
+        expected: Expected hex digest from the server metadata, or ``None``.
+        actual: Computed hex digest from the downloaded bytes, or ``None``.
+    """
+
+    def __init__(
+        self,
+        message: str,
+        *,
+        algorithm: str | None = None,
+        expected: str | None = None,
+        actual: str | None = None,
+    ):
+        super().__init__(message)
+        self.algorithm = algorithm
+        self.expected = expected
+        self.actual = actual
+
+
+class TeaValidationError(TeaError):
+    """Malformed server response that fails Pydantic model validation."""
+
+
+class TeaInsecureTransportWarning(UserWarning):
+    """Warning emitted when using plaintext HTTP instead of HTTPS.
+
+    Triggered by :class:`~libtea.client.TeaClient` or :func:`~libtea.discovery.fetch_well_known`
+    when the ``scheme`` is ``"http"``.
+    """
+
+
+__all__ = [
+    "TeaAuthenticationError",
+    "TeaChecksumError",
+    "TeaConnectionError",
+    "TeaDiscoveryError",
+    "TeaError",
+    "TeaInsecureTransportWarning",
+    "TeaNotFoundError",
+    "TeaRequestError",
+    "TeaServerError",
+    "TeaValidationError",
+]
diff --git a/src/libtea/models.py b/src/libtea/models.py
new file mode 100644
index 0000000..dd96515
--- /dev/null
+++ b/src/libtea/models.py
@@ -0,0 +1,533 @@
+"""Pydantic v2 data models for TEA API objects.
+
+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.
+"""
+
+from datetime import datetime
+from enum import StrEnum
+from typing import Literal, Self
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+from pydantic.alias_generators import to_camel
+
+
+class _TeaModel(BaseModel):
+    """Base model for all TEA API objects.
+
+    Configuration:
+        - ``alias_generator=to_camel``: JSON keys use camelCase.
+        - ``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).
+    """
+
+    model_config = ConfigDict(
+        alias_generator=to_camel,
+        populate_by_name=True,
+        extra="ignore",  # forward-compat: silently drop unknown fields from future spec versions
+        frozen=True,
+    )
+
+
+# --- Enums ---
+
+
+class IdentifierType(StrEnum):
+    """Identifier type used in product and component identifiers.
+
+    Note: ``Identifier.id_type`` is typed as ``str`` (not ``IdentifierType``)
+    so unknown types from future spec versions pass through without error.
+    """
+
+    CPE = "CPE"
+    TEI = "TEI"
+    PURL = "PURL"
+
+
+class TeiType(StrEnum):
+    """TEI URN scheme types per TEA discovery specification.
+
+    These are the valid ```` values in a TEI URN
+    (``urn:tei:::``).
+    """
+
+    UUID = "uuid"
+    PURL = "purl"
+    HASH = "hash"
+    SWID = "swid"
+    EANUPC = "eanupc"
+    GTIN = "gtin"
+    ASIN = "asin"
+    UDI = "udi"
+
+
+class ChecksumAlgorithm(StrEnum):
+    """Checksum algorithm identifiers per TEA spec.
+
+    Values use hyphen form (e.g. ``SHA-256``). The Checksum model's validator
+    normalizes underscore form (``SHA_256``) to hyphen form automatically.
+    """
+
+    MD5 = "MD5"
+    SHA_1 = "SHA-1"
+    SHA_256 = "SHA-256"
+    SHA_384 = "SHA-384"
+    SHA_512 = "SHA-512"
+    SHA3_256 = "SHA3-256"
+    SHA3_384 = "SHA3-384"
+    SHA3_512 = "SHA3-512"
+    BLAKE2B_256 = "BLAKE2b-256"
+    BLAKE2B_384 = "BLAKE2b-384"
+    BLAKE2B_512 = "BLAKE2b-512"
+    BLAKE3 = "BLAKE3"
+
+
+_CHECKSUM_VALUES = frozenset(e.value for e in ChecksumAlgorithm)
+_CHECKSUM_NAME_TO_VALUE = {e.name: e.value for e in ChecksumAlgorithm}
+
+
+def normalize_algorithm_name(name: str) -> str:
+    """Normalize a checksum algorithm name from underscore form to hyphen form.
+
+    Maps enum member names (e.g. ``SHA_256``) to their values (``SHA-256``).
+    Returns the input unchanged if it is already a valid value or unknown.
+    """
+    return _CHECKSUM_NAME_TO_VALUE.get(name, name)
+
+
+class ArtifactType(StrEnum):
+    """Type of a TEA artifact (e.g. BOM, VEX, attestation)."""
+
+    ATTESTATION = "ATTESTATION"
+    BOM = "BOM"
+    BUILD_META = "BUILD_META"
+    CERTIFICATION = "CERTIFICATION"
+    FORMULATION = "FORMULATION"
+    LICENSE = "LICENSE"
+    RELEASE_NOTES = "RELEASE_NOTES"
+    SECURITY_TXT = "SECURITY_TXT"
+    THREAT_MODEL = "THREAT_MODEL"
+    VULNERABILITIES = "VULNERABILITIES"
+    OTHER = "OTHER"
+
+
+class CollectionBelongsTo(StrEnum):
+    """Whether a collection belongs to a component release or product release."""
+
+    COMPONENT_RELEASE = "COMPONENT_RELEASE"
+    PRODUCT_RELEASE = "PRODUCT_RELEASE"
+
+
+class CollectionUpdateReasonType(StrEnum):
+    """Reason for a collection version update."""
+
+    INITIAL_RELEASE = "INITIAL_RELEASE"
+    VEX_UPDATED = "VEX_UPDATED"
+    ARTIFACT_UPDATED = "ARTIFACT_UPDATED"
+    ARTIFACT_ADDED = "ARTIFACT_ADDED"
+    ARTIFACT_REMOVED = "ARTIFACT_REMOVED"
+
+
+class ErrorType(StrEnum):
+    """TEA API error types returned in 404 responses."""
+
+    OBJECT_UNKNOWN = "OBJECT_UNKNOWN"
+    OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE"
+
+
+# --- Shared types ---
+
+
+class Identifier(_TeaModel):
+    """An identifier with a specified type (e.g. PURL, CPE, TEI).
+
+    The ``id_type`` field accepts any string for forward-compatibility with
+    future TEA spec versions. Compare against :class:`IdentifierType` members
+    for known types (e.g. ``ident.id_type == IdentifierType.PURL``).
+    """
+
+    id_type: str
+    id_value: str
+
+
+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.
+    """
+
+    algorithm_type: ChecksumAlgorithm = Field(alias="algType")
+    algorithm_value: str = Field(alias="algValue")
+
+    @field_validator("algorithm_type", mode="before")
+    @classmethod
+    def normalize_algorithm_type(cls, v: str) -> str:
+        """Normalize underscore form (SHA_256) to hyphen form (SHA-256).
+
+        Uses member-name lookup instead of blind replace to handle
+        BLAKE2b casing correctly (BLAKE2B_256 -> BLAKE2b-256).
+        """
+        if isinstance(v, str) and v not in _CHECKSUM_VALUES:
+            mapped = _CHECKSUM_NAME_TO_VALUE.get(v)
+            if mapped is not None:
+                return mapped
+        return v
+
+
+# --- Domain objects ---
+
+
+class ReleaseDistribution(_TeaModel):
+    """A distribution format for a component release (e.g. binary, source)."""
+
+    distribution_type: str
+    description: str | None = None
+    identifiers: tuple[Identifier, ...] = ()
+    url: str | None = None
+    signature_url: str | None = None
+    checksums: tuple[Checksum, ...] = ()
+
+
+class ArtifactFormat(_TeaModel):
+    """A TEA artifact in a specific format with download URL and checksums."""
+
+    media_type: str
+    description: str | None = None
+    url: str
+    signature_url: str | None = None
+    checksums: tuple[Checksum, ...] = ()
+
+
+class Artifact(_TeaModel):
+    """A security-related artifact (e.g. SBOM, VEX, attestation) with available formats."""
+
+    uuid: str
+    name: str
+    type: ArtifactType
+    distribution_types: tuple[str, ...] | None = None
+    formats: tuple[ArtifactFormat, ...] = ()
+
+
+class CollectionUpdateReason(_TeaModel):
+    """Reason for a collection version update, with optional comment."""
+
+    type: CollectionUpdateReasonType
+    comment: str | None = None
+
+
+class Collection(_TeaModel):
+    """A versioned collection of artifacts belonging to a release.
+
+    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.
+
+    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.
+        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.
+    """
+
+    uuid: str | None = None
+    version: int | None = Field(default=None, ge=1)
+    date: datetime | None = None
+    belongs_to: CollectionBelongsTo | None = None
+    update_reason: CollectionUpdateReason | None = None
+    artifacts: tuple[Artifact, ...] = ()
+
+
+class ComponentRef(_TeaModel):
+    """Reference to a TEA component, optionally pinned to a specific release."""
+
+    uuid: str
+    release: str | None = None
+
+
+class Component(_TeaModel):
+    """A TEA component (software lineage/family, not a specific version)."""
+
+    uuid: str
+    name: str
+    identifiers: tuple[Identifier, ...]
+
+
+class Release(_TeaModel):
+    """A specific version of a TEA component with distributions and identifiers.
+
+    Attributes:
+        uuid: Server-assigned UUID.
+        component: UUID of the parent component (set when returned in context).
+        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.
+        release_date: Actual release date (may differ from ``created_date``).
+        pre_release: ``True`` if this is a pre-release / unstable version.
+        identifiers: External identifiers (PURLs, CPEs, etc.).
+        distributions: Available distribution formats (binary, source, etc.).
+    """
+
+    uuid: str
+    component: str | None = None
+    component_name: str | None = None
+    version: str
+    created_date: datetime
+    release_date: datetime | None = None
+    pre_release: bool | None = None
+    identifiers: tuple[Identifier, ...] = ()
+    distributions: tuple[ReleaseDistribution, ...] = ()
+
+
+class ComponentReleaseWithCollection(_TeaModel):
+    """A component release bundled with its latest collection.
+
+    Returned by ``GET /componentRelease/{uuid}``.
+    """
+
+    release: Release
+    latest_collection: Collection
+
+
+class Product(_TeaModel):
+    """A TEA product (optional grouping of components)."""
+
+    uuid: str
+    name: str
+    identifiers: tuple[Identifier, ...]
+
+
+class ProductRelease(_TeaModel):
+    """A specific version of a TEA product with its component references.
+
+    This is the primary entry point from TEI discovery — resolving a TEI
+    typically yields a product release UUID.
+
+    Attributes:
+        uuid: Server-assigned UUID.
+        product: UUID of the parent product (set when returned in context).
+        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.
+        release_date: Actual release date (may differ from ``created_date``).
+        pre_release: ``True`` if this is a pre-release / unstable version.
+        identifiers: External identifiers (PURLs, CPEs, etc.).
+        components: References to the components included in this product release.
+    """
+
+    uuid: str
+    product: str | None = None
+    product_name: str | None = None
+    version: str
+    created_date: datetime
+    release_date: datetime | None = None
+    pre_release: bool | None = None
+    identifiers: tuple[Identifier, ...] = ()
+    components: tuple[ComponentRef, ...]
+
+
+# --- CLE (Common Lifecycle Enumeration) ---
+
+
+class CLEEventType(StrEnum):
+    """CLE lifecycle event types per ECMA-428 TC54 TG3 CLE Specification v1.0.0."""
+
+    RELEASED = "released"
+    END_OF_DEVELOPMENT = "endOfDevelopment"
+    END_OF_SUPPORT = "endOfSupport"
+    END_OF_LIFE = "endOfLife"
+    END_OF_DISTRIBUTION = "endOfDistribution"
+    END_OF_MARKETING = "endOfMarketing"
+    SUPERSEDED_BY = "supersededBy"
+    COMPONENT_RENAMED = "componentRenamed"
+    WITHDRAWN = "withdrawn"
+
+
+class CLEVersionSpecifier(_TeaModel):
+    """A version specifier: either a single version or a version range in vers format.
+
+    At least one of ``version`` or ``range`` must be set.
+    """
+
+    version: str | None = None
+    range: str | None = None
+
+    @model_validator(mode="after")
+    def _check_at_least_one_field(self) -> Self:
+        if self.version is None and self.range is None:
+            raise ValueError("CLEVersionSpecifier requires at least one of 'version' or 'range'")
+        return self
+
+
+class CLESupportDefinition(_TeaModel):
+    """A support policy definition referenced by CLE events."""
+
+    id: str
+    description: str
+    url: str | None = None
+
+
+class CLEDefinitions(_TeaModel):
+    """Container for reusable CLE policy definitions."""
+
+    support: tuple[CLESupportDefinition, ...] | None = None
+
+
+class CLEEvent(_TeaModel):
+    """A discrete lifecycle event from the CLE specification.
+
+    Required fields: ``id``, ``type``, ``effective``, ``published``.
+    Other fields are event-type-specific.
+
+    Attributes:
+        id: Unique event identifier within the CLE document.
+        type: Lifecycle event type (e.g. ``released``, ``endOfLife``).
+        effective: When the event takes/took effect.
+        published: When the event was published.
+        version: Single version this event applies to (e.g. for ``released``).
+        versions: Version range specifiers (e.g. for ``endOfSupport``).
+        support_id: Reference to a :class:`CLESupportDefinition` id.
+        license: SPDX license expression (for ``released`` events).
+        superseded_by_version: Replacement version (for ``supersededBy`` events).
+        identifiers: External identifiers associated with this event.
+        event_id: Reference to another event id (for ``withdrawn`` events).
+        reason: Human-readable reason for the event.
+        description: Additional description or context.
+        references: List of reference URLs.
+    """
+
+    id: int
+    type: CLEEventType
+    effective: datetime
+    published: datetime
+    version: str | None = None
+    versions: tuple[CLEVersionSpecifier, ...] | None = None
+    support_id: str | None = None
+    license: str | None = None
+    superseded_by_version: str | None = None
+    identifiers: tuple[Identifier, ...] | None = None
+    event_id: int | None = None
+    reason: str | None = None
+    description: str | None = None
+    references: tuple[str, ...] | None = None
+
+
+class CLE(_TeaModel):
+    """Common Lifecycle Enumeration document per ECMA-428 TC54 TG3 v1.0.0.
+
+    Contains lifecycle events and optional definitions. Event ordering is determined by the producer.
+    """
+
+    events: tuple[CLEEvent, ...]
+    definitions: CLEDefinitions | None = None
+
+
+# --- Pagination ---
+
+
+class PaginatedProductResponse(_TeaModel):
+    """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):
+    """Paginated response containing a list of product releases."""
+
+    timestamp: datetime
+    page_start_index: int
+    page_size: int
+    total_results: int
+    results: tuple[ProductRelease, ...] = ()
+
+
+# --- Discovery types ---
+
+
+class TeaEndpoint(_TeaModel):
+    """A TEA server endpoint from the .well-known/tea discovery document.
+
+    Attributes:
+        url: Base URL of the endpoint (e.g. ``https://tea.example.com/api``).
+        versions: SemVer version strings this endpoint supports (at least one).
+        priority: Optional priority hint between 0.0 (lowest) and 1.0 (highest).
+            Defaults to 1.0 per spec when not specified.
+    """
+
+    url: str
+    versions: tuple[str, ...] = Field(min_length=1)
+    priority: float | None = Field(default=None, ge=0, le=1)
+
+
+class TeaWellKnown(_TeaModel):
+    """The .well-known/tea discovery document listing available TEA endpoints."""
+
+    schema_version: Literal[1]
+    endpoints: tuple[TeaEndpoint, ...] = Field(min_length=1)
+
+
+class TeaServerInfo(_TeaModel):
+    """TEA server info returned from the discovery API endpoint."""
+
+    root_url: str
+    versions: tuple[str, ...] = Field(min_length=1)
+    priority: float | None = Field(default=None, ge=0, le=1)
+
+
+class DiscoveryInfo(_TeaModel):
+    """Discovery result mapping a TEI to a product release and its servers.
+
+    Returned by ``GET /discovery?tei=...``. Each result provides the UUID
+    of the matching product release and the list of servers that host it.
+    """
+
+    product_release_uuid: str
+    servers: tuple[TeaServerInfo, ...] = Field(min_length=1)
+
+
+__all__ = [
+    # Enums
+    "ArtifactType",
+    "ChecksumAlgorithm",
+    "CLEEventType",
+    "CollectionBelongsTo",
+    "CollectionUpdateReasonType",
+    "ErrorType",
+    "IdentifierType",
+    "TeiType",
+    # Models
+    "Artifact",
+    "ArtifactFormat",
+    "CLE",
+    "CLEDefinitions",
+    "CLEEvent",
+    "CLESupportDefinition",
+    "CLEVersionSpecifier",
+    "Checksum",
+    "Collection",
+    "CollectionUpdateReason",
+    "Component",
+    "ComponentRef",
+    "ComponentReleaseWithCollection",
+    "DiscoveryInfo",
+    "Identifier",
+    "PaginatedProductReleaseResponse",
+    "PaginatedProductResponse",
+    "Product",
+    "ProductRelease",
+    "Release",
+    "ReleaseDistribution",
+    "TeaEndpoint",
+    "TeaServerInfo",
+    "TeaWellKnown",
+    # Helpers
+    "normalize_algorithm_name",
+]
diff --git a/libtea/py.typed b/src/libtea/py.typed
similarity index 100%
rename from libtea/py.typed
rename to src/libtea/py.typed
diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
new file mode 100644
index 0000000..48ecf9d
--- /dev/null
+++ b/tests/cli/test_cli.py
@@ -0,0 +1,1334 @@
+"""Tests for the tea-cli CLI."""
+
+import json
+import re
+
+import pytest
+import responses
+
+typer = pytest.importorskip("typer", reason="typer not installed (install libtea[cli])")
+
+from typer.testing import CliRunner  # noqa: E402
+
+import libtea.cli  # noqa: E402
+from libtea.cli import app  # noqa: E402
+
+runner = CliRunner()
+
+BASE_URL = "https://api.example.com/tea/v1"
+
+_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+
+
+def _strip_ansi(text: str) -> str:
+    return _ANSI_RE.sub("", text)
+
+
+@pytest.fixture(autouse=True)
+def _reset_cli_flags():
+    """Reset module-level CLI flags between test invocations."""
+    libtea.cli._json_output = False
+    yield
+    libtea.cli._json_output = False
+
+
+class TestCliEntryPoint:
+    """P0-1: Entry point wrapper handles missing typer gracefully."""
+
+    def test_entry_point_importable(self):
+        from libtea._cli_entry import main
+
+        assert callable(main)
+
+    def test_entry_point_registered_in_pyproject(self):
+        """Verify pyproject.toml points to the wrapper, not directly to cli:app."""
+        from pathlib import Path
+
+        pyproject = Path(__file__).parent.parent.parent / "pyproject.toml"
+        content = pyproject.read_text()
+        assert 'tea-cli = "libtea._cli_entry:main"' in content
+
+
+class TestCLINoServer:
+    def test_no_base_url_or_domain_errors(self):
+        result = runner.invoke(app, ["get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"])
+        assert result.exit_code == 1
+
+    def test_both_base_url_and_domain_errors(self):
+        result = runner.invoke(
+            app,
+            ["get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL, "--domain", "example.com"],
+        )
+        assert result.exit_code == 1
+
+    def test_version_flag(self):
+        result = runner.invoke(app, ["--version"])
+        assert result.exit_code == 0
+        assert "tea-cli" in result.output
+
+    def test_help(self):
+        result = runner.invoke(app, ["--help"])
+        assert result.exit_code == 0
+        assert "discover" in result.output
+        assert "inspect" in result.output
+
+
+class TestCLICommands:
+    @responses.activate
+    def test_get_product(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["--json", "get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["name"] == "Test Product"
+
+    @responses.activate
+    def test_discover(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        result = runner.invoke(app, ["--json", "discover", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data) == 1
+
+    @responses.activate
+    def test_get_artifact(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            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])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["name"] == "SBOM"
+
+    @responses.activate
+    def test_search_products(self):
+        responses.get(
+            f"{BASE_URL}/products",
+            json={
+                "timestamp": "2024-01-01T00:00:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 0,
+                "results": [],
+            },
+        )
+        result = runner.invoke(
+            app, ["search-products", "--id-type", "PURL", "--id-value", "pkg:pypi/test", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_search_releases(self):
+        responses.get(
+            f"{BASE_URL}/productReleases",
+            json={
+                "timestamp": "2024-01-01T00:00:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 0,
+                "results": [],
+            },
+        )
+        result = runner.invoke(
+            app, ["search-releases", "--id-type", "PURL", "--id-value", "pkg:pypi/test", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_release_product(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [],
+            },
+        )
+        result = runner.invoke(app, ["get-release", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_release_component(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/componentRelease/{uuid}",
+            json={
+                "release": {
+                    "uuid": uuid,
+                    "version": "1.0.0",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                },
+                "latestCollection": {
+                    "uuid": uuid,
+                    "version": 1,
+                    "artifacts": [],
+                },
+            },
+        )
+        result = runner.invoke(app, ["get-release", uuid, "--component", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_collection_latest(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/collection/latest",
+            json={"uuid": uuid, "version": 1, "artifacts": []},
+        )
+        result = runner.invoke(app, ["get-collection", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_collection_by_version(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/collection/2",
+            json={"uuid": uuid, "version": 2, "artifacts": []},
+        )
+        result = runner.invoke(app, ["get-collection", uuid, "--version", "2", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_collection_component(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/componentRelease/{uuid}/collection/latest",
+            json={"uuid": uuid, "version": 1, "artifacts": []},
+        )
+        result = runner.invoke(app, ["get-collection", uuid, "--component", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_collection_component_with_version(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/componentRelease/{uuid}/collection/3",
+            json={"uuid": uuid, "version": 3, "artifacts": []},
+        )
+        result = runner.invoke(app, ["get-collection", uuid, "--component", "--version", "3", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_download(self, tmp_path):
+        artifact_url = "https://cdn.example.com/sbom.json"
+        responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}')
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(app, ["download", artifact_url, str(dest), "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert dest.exists()
+
+    @responses.activate
+    def test_download_with_checksum(self, tmp_path):
+        import hashlib
+
+        content = b'{"bomFormat": "CycloneDX"}'
+        artifact_url = "https://cdn.example.com/sbom.json"
+        responses.get(artifact_url, body=content)
+        sha256 = hashlib.sha256(content).hexdigest()
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(
+            app,
+            ["download", artifact_url, str(dest), "--checksum", f"SHA-256:{sha256}", "--base-url", BASE_URL],
+        )
+        assert result.exit_code == 0
+        assert dest.exists()
+
+    def test_download_invalid_checksum_format(self, tmp_path):
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(
+            app, ["download", "https://cdn.example.com/f", str(dest), "--checksum", "badhash", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    def test_download_unknown_algorithm(self, tmp_path):
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(
+            app,
+            ["download", "https://cdn.example.com/f", str(dest), "--checksum", "BOGUS:abc123", "--base-url", BASE_URL],
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_download_checksum_underscore_normalization(self, tmp_path):
+        """Underscore form (SHA_256) is normalized to hyphen form (SHA-256)."""
+        artifact_url = "https://cdn.example.com/sbom.json"
+        content = b'{"bomFormat": "CycloneDX"}'
+        import hashlib
+
+        sha256 = hashlib.sha256(content).hexdigest()
+        responses.get(artifact_url, body=content)
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(
+            app,
+            ["download", artifact_url, str(dest), "--checksum", f"SHA_256:{sha256}", "--base-url", BASE_URL],
+        )
+        assert result.exit_code == 0
+        assert dest.exists()
+
+    @responses.activate
+    def test_download_with_max_download_bytes(self, tmp_path):
+        artifact_url = "https://cdn.example.com/sbom.json"
+        responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}')
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(
+            app,
+            ["download", artifact_url, str(dest), "--max-download-bytes", "10000", "--base-url", BASE_URL],
+        )
+        assert result.exit_code == 0
+        assert dest.exists()
+
+    @responses.activate
+    def test_inspect(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        comp_uuid = "c3d4e5f6-a7b8-9012-cdef-123456789012"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": comp_uuid, "release": comp_uuid}],
+            },
+        )
+        responses.get(
+            f"{BASE_URL}/componentRelease/{comp_uuid}",
+            json={
+                "release": {"uuid": comp_uuid, "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"},
+                "latestCollection": {"uuid": comp_uuid, "version": 1, "artifacts": []},
+            },
+        )
+        result = runner.invoke(app, ["--json", "inspect", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data) == 1
+        assert data[0]["productRelease"]["uuid"] == uuid
+        assert len(data[0]["components"]) == 1
+        assert "discovery" in data[0]
+        assert data[0]["discovery"]["productReleaseUuid"] == uuid
+
+    def test_error_output_goes_to_stderr(self):
+        result = runner.invoke(app, ["get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"])
+        assert result.exit_code == 1
+        assert "Error:" in result.output
+
+
+class TestCLIErrorPaths:
+    @responses.activate
+    def test_get_product_server_error(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{BASE_URL}/product/{uuid}", status=500)
+        result = runner.invoke(app, ["get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_discover_not_found(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        responses.get(f"{BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"})
+        result = runner.invoke(app, ["discover", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+
+class TestCLIDiscoveryPath:
+    """P2-3: Tests for --domain discovery path."""
+
+    @responses.activate
+    def test_domain_discovery(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("https://api.example.com/v0.3.0-beta.2", status=200)
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            "https://api.example.com/v0.3.0-beta.2/product/" + uuid,
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["--json", "get-product", uuid, "--domain", "example.com"])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["name"] == "Test Product"
+
+    @responses.activate
+    def test_domain_discovery_with_http(self):
+        responses.get(
+            "http://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "http://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("http://api.example.com/v0.3.0-beta.2", status=200)
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            "http://api.example.com/v0.3.0-beta.2/product/" + uuid,
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["get-product", uuid, "--domain", "example.com", "--use-http"])
+        assert result.exit_code == 0
+
+
+class TestCLIAuthOptions:
+    """P1-4: Tests for --auth and mTLS CLI options."""
+
+    @responses.activate
+    def test_basic_auth_option(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["get-product", uuid, "--base-url", BASE_URL, "--auth", "user:pass"])
+        assert result.exit_code == 0
+        assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
+
+    def test_invalid_auth_format(self):
+        result = runner.invoke(
+            app, ["get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL, "--auth", "nopassword"]
+        )
+        assert result.exit_code == 1
+
+    def test_client_key_without_cert_errors(self):
+        result = runner.invoke(
+            app,
+            [
+                "get-product",
+                "d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+                "--base-url",
+                BASE_URL,
+                "--client-key",
+                "/tmp/key.pem",
+            ],
+        )
+        assert result.exit_code == 1
+
+    def test_client_cert_without_key_errors(self):
+        result = runner.invoke(
+            app,
+            [
+                "get-product",
+                "d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+                "--base-url",
+                BASE_URL,
+                "--client-cert",
+                "/tmp/cert.pem",
+            ],
+        )
+        assert result.exit_code == 1
+
+
+class TestCLIInspectOptions:
+    """P3-7: Tests for inspect --max-components."""
+
+    @responses.activate
+    def test_inspect_max_components_truncates(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        comp_uuids = [
+            "c0000000-0000-0000-0000-000000000000",
+            "c0000000-0000-0000-0000-000000000001",
+            "c0000000-0000-0000-0000-000000000002",
+            "c0000000-0000-0000-0000-000000000003",
+            "c0000000-0000-0000-0000-000000000004",
+        ]
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": c, "release": c} for c in comp_uuids],
+            },
+        )
+        for c in comp_uuids[:2]:
+            responses.get(
+                f"{BASE_URL}/componentRelease/{c}",
+                json={
+                    "release": {"uuid": c, "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"},
+                    "latestCollection": {"uuid": c, "version": 1, "artifacts": []},
+                },
+            )
+        result = runner.invoke(app, ["--json", "inspect", tei, "--max-components", "2", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        output = result.output
+        # CliRunner mixes stdout/stderr; extract JSON array from the output
+        json_start = output.index("[")
+        json_end = output.rindex("]") + 1
+        data = json.loads(output[json_start:json_end])
+        assert len(data[0]["components"]) == 2
+        assert data[0]["truncated"] is True
+        assert data[0]["totalComponents"] == 5
+        assert "Warning: truncated" in output
+
+
+class TestCLIMoreErrorPaths:
+    """Additional CLI error path coverage."""
+
+    @responses.activate
+    def test_search_products_error(self):
+        responses.get(f"{BASE_URL}/products", status=500)
+        result = runner.invoke(
+            app, ["search-products", "--id-type", "PURL", "--id-value", "pkg:pypi/test", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_search_releases_error(self):
+        responses.get(f"{BASE_URL}/productReleases", status=500)
+        result = runner.invoke(
+            app, ["search-releases", "--id-type", "PURL", "--id-value", "pkg:pypi/test", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_release_error(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{BASE_URL}/productRelease/{uuid}", status=500)
+        result = runner.invoke(app, ["get-release", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_collection_error(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{BASE_URL}/productRelease/{uuid}/collection/latest", status=500)
+        result = runner.invoke(app, ["get-collection", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_artifact_error(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        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
+
+    @responses.activate
+    def test_download_server_error(self, tmp_path):
+        artifact_url = "https://cdn.example.com/sbom.json"
+        responses.get(artifact_url, status=500)
+        dest = tmp_path / "sbom.json"
+        result = runner.invoke(app, ["download", artifact_url, str(dest), "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_inspect_error(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        responses.get(f"{BASE_URL}/discovery", status=500)
+        result = runner.invoke(app, ["inspect", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+
+class TestCLIInspectGetComponentFallback:
+    """Test the inspect command's get_component fallback for ComponentRef without release."""
+
+    @responses.activate
+    def test_inspect_component_ref_without_release_no_releases(self):
+        """Unpinned component with no releases — shows basic component data only."""
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        comp_uuid = "c3d4e5f6-a7b8-9012-cdef-123456789099"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": comp_uuid}],
+            },
+        )
+        responses.get(
+            f"{BASE_URL}/component/{comp_uuid}",
+            json={"uuid": comp_uuid, "name": "Component Without Release", "identifiers": []},
+        )
+        responses.get(f"{BASE_URL}/component/{comp_uuid}/releases", json=[])
+        result = runner.invoke(app, ["--json", "inspect", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data[0]["components"]) == 1
+        assert data[0]["components"][0]["name"] == "Component Without Release"
+        assert "resolvedRelease" not in data[0]["components"][0]
+
+    @responses.activate
+    def test_inspect_component_ref_resolves_latest_release(self):
+        """Unpinned component with releases — resolves latest and includes artifacts."""
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        comp_uuid = "c3d4e5f6-a7b8-9012-cdef-123456789099"
+        rel_uuid = "d4e5f6a7-b8c9-0123-defa-456789012345"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": comp_uuid}],
+            },
+        )
+        responses.get(
+            f"{BASE_URL}/component/{comp_uuid}",
+            json={"uuid": comp_uuid, "name": "App Component", "identifiers": []},
+        )
+        responses.get(
+            f"{BASE_URL}/component/{comp_uuid}/releases",
+            json=[{"uuid": rel_uuid, "version": "2.0.0", "createdDate": "2024-06-01T00:00:00Z"}],
+        )
+        responses.get(
+            f"{BASE_URL}/componentRelease/{rel_uuid}",
+            json={
+                "release": {"uuid": rel_uuid, "version": "2.0.0", "createdDate": "2024-06-01T00:00:00Z"},
+                "latestCollection": {
+                    "uuid": uuid,
+                    "version": 1,
+                    "artifacts": [
+                        {
+                            "uuid": rel_uuid,
+                            "name": "SBOM",
+                            "type": "BOM",
+                            "formats": [{"mediaType": "application/json", "url": "https://cdn/sbom.json"}],
+                        }
+                    ],
+                },
+            },
+        )
+        result = runner.invoke(app, ["--json", "inspect", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        comp = data[0]["components"][0]
+        assert comp["name"] == "App Component"
+        assert comp["resolvedNote"] == "latest release (not pinned)"
+        assert comp["resolvedRelease"]["release"]["version"] == "2.0.0"
+        assert comp["resolvedRelease"]["latestCollection"]["artifacts"][0]["name"] == "SBOM"
+
+    @responses.activate
+    def test_inspect_component_ref_release_resolution_error(self):
+        """Unpinned component where release resolution fails — falls back to basic data."""
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        comp_uuid = "c3d4e5f6-a7b8-9012-cdef-123456789099"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}",
+            json={
+                "uuid": uuid,
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": comp_uuid}],
+            },
+        )
+        responses.get(
+            f"{BASE_URL}/component/{comp_uuid}",
+            json={"uuid": comp_uuid, "name": "Broken Component", "identifiers": []},
+        )
+        responses.get(f"{BASE_URL}/component/{comp_uuid}/releases", status=500)
+        result = runner.invoke(app, ["--json", "inspect", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        comp = data[0]["components"][0]
+        assert comp["name"] == "Broken Component"
+        assert "resolvedRelease" not in comp
+
+
+class TestCLITeiAutoDiscovery:
+    """Test TEI auto-discovery: when neither --base-url nor --domain is given."""
+
+    @responses.activate
+    def test_discover_auto_extracts_domain_from_tei(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("https://api.example.com/v0.3.0-beta.2", status=200)
+        responses.get(
+            "https://api.example.com/v0.3.0-beta.2/discovery",
+            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
+        data = json.loads(result.output)
+        assert len(data) == 1
+
+
+class TestCLIEntryPointErrors:
+    """Test _cli_entry.py error handling."""
+
+    def test_cli_entry_import_error(self):
+        """Test that _cli_entry handles missing typer gracefully."""
+        from libtea._cli_entry import main
+
+        assert callable(main)
+
+    def test_cli_entry_main_invokes_app(self):
+        """Test that main() calls app() when typer is available."""
+        from unittest.mock import patch
+
+        with patch("libtea.cli.app") as mock_app:
+            from libtea._cli_entry import main
+
+            main()
+            mock_app.assert_called_once()
+
+
+class TestCLIJsonFlag:
+    """Tests for the --json flag and default rich output."""
+
+    @responses.activate
+    def test_json_flag_produces_valid_json(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["--json", "get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["uuid"] == uuid
+        assert data["name"] == "Test Product"
+
+    @responses.activate
+    def test_default_output_is_rich_not_json(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        # Rich output should NOT be valid JSON
+        with pytest.raises(json.JSONDecodeError):
+            json.loads(result.output)
+        # But should contain key data
+        assert "Test Product" in result.output
+        assert uuid in result.output
+
+
+class TestCLIDebugFlag:
+    """Tests for the --debug / -d flag."""
+
+    @responses.activate
+    def test_debug_flag_produces_debug_output(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["--debug", "--json", "get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        # Debug output goes to stderr; typer CliRunner captures both in output
+        combined = result.output + (result.stderr if hasattr(result, "stderr") else "")
+        # Should still produce valid JSON on stdout
+        assert "Test Product" in combined
+
+    @responses.activate
+    def test_debug_short_flag(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}",
+            json={"uuid": uuid, "name": "Test Product", "identifiers": []},
+        )
+        result = runner.invoke(app, ["-d", "--json", "get-product", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    def test_debug_flag_shown_in_help(self):
+        result = runner.invoke(app, ["--help"])
+        plain = _strip_ansi(result.output)
+        assert "--debug" in plain
+        assert "-d" in plain
+
+
+class TestCLIDiscoverQuiet:
+    """Tests for the discover --quiet / -q flag."""
+
+    @responses.activate
+    def test_quiet_outputs_uuid_only(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        result = runner.invoke(app, ["discover", "--quiet", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert result.output.strip() == uuid
+
+    @responses.activate
+    def test_quiet_short_flag(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid,
+                    "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        result = runner.invoke(app, ["discover", "-q", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert result.output.strip() == uuid
+
+    @responses.activate
+    def test_quiet_multiple_results(self):
+        tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0"
+        uuid1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+        uuid2 = "b2c3d4e5-f6a7-8901-bcde-f12345678901"
+        responses.get(
+            f"{BASE_URL}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": uuid1,
+                    "servers": [{"rootUrl": "https://tea1.example.com", "versions": ["1.0.0"]}],
+                },
+                {
+                    "productReleaseUuid": uuid2,
+                    "servers": [{"rootUrl": "https://tea2.example.com", "versions": ["1.0.0"]}],
+                },
+            ],
+        )
+        result = runner.invoke(app, ["discover", "-q", tei, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        lines = result.output.strip().split("\n")
+        assert lines == [uuid1, uuid2]
+
+    def test_quiet_flag_shown_in_help(self):
+        result = runner.invoke(app, ["discover", "--help"])
+        plain = _strip_ansi(result.output)
+        assert "--quiet" in plain
+        assert "-q" in plain
+
+
+class TestNewCommands:
+    """Tests for newly added CLI commands: get-product-releases, get-component,
+    get-component-releases, list-collections, get-cle."""
+
+    @responses.activate
+    def test_get_product_releases(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}/releases",
+            json={
+                "timestamp": "2024-01-01T00:00:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 1,
+                "results": [
+                    {
+                        "uuid": uuid,
+                        "version": "1.0.0",
+                        "createdDate": "2024-01-01T00:00:00Z",
+                        "components": [],
+                    }
+                ],
+            },
+        )
+        result = runner.invoke(app, ["--json", "get-product-releases", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["totalResults"] == 1
+        assert data["results"][0]["version"] == "1.0.0"
+
+    @responses.activate
+    def test_get_product_releases_with_pagination(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}/releases",
+            json={
+                "timestamp": "2024-01-01T00:00:00Z",
+                "pageStartIndex": 10,
+                "pageSize": 5,
+                "totalResults": 20,
+                "results": [],
+            },
+        )
+        result = runner.invoke(
+            app,
+            ["--json", "get-product-releases", uuid, "--page-offset", "10", "--page-size", "5", "--base-url", BASE_URL],
+        )
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["pageStartIndex"] == 10
+
+    @responses.activate
+    def test_get_component(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/component/{uuid}",
+            json={"uuid": uuid, "name": "My Component", "identifiers": []},
+        )
+        result = runner.invoke(app, ["--json", "get-component", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["name"] == "My Component"
+
+    @responses.activate
+    def test_get_component_rich_output(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/component/{uuid}",
+            json={"uuid": uuid, "name": "My Component", "identifiers": []},
+        )
+        result = runner.invoke(app, ["get-component", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert "My Component" in result.output
+
+    @responses.activate
+    def test_get_component_releases(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        rel_uuid = "e5e0a65b-bddf-22ff-bd8a-2b63a25e55c2"
+        responses.get(
+            f"{BASE_URL}/component/{uuid}/releases",
+            json=[
+                {"uuid": rel_uuid, "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"},
+                {"uuid": uuid, "version": "2.0.0", "createdDate": "2024-06-01T00:00:00Z"},
+            ],
+        )
+        result = runner.invoke(app, ["--json", "get-component-releases", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data) == 2
+        assert data[0]["version"] == "1.0.0"
+
+    @responses.activate
+    def test_get_component_releases_rich_output(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/component/{uuid}/releases",
+            json=[
+                {
+                    "uuid": uuid,
+                    "version": "1.0.0",
+                    "componentName": "App",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                },
+            ],
+        )
+        result = runner.invoke(app, ["get-component-releases", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert "Component Releases" in result.output
+
+    @responses.activate
+    def test_list_collections_product_release(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/collections",
+            json=[
+                {"uuid": uuid, "version": 1, "artifacts": []},
+                {"uuid": uuid, "version": 2, "artifacts": []},
+            ],
+        )
+        result = runner.invoke(app, ["--json", "list-collections", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data) == 2
+
+    @responses.activate
+    def test_list_collections_component_release(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/componentRelease/{uuid}/collections",
+            json=[{"uuid": uuid, "version": 1, "artifacts": []}],
+        )
+        result = runner.invoke(app, ["--json", "list-collections", uuid, "--component", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data) == 1
+
+    @responses.activate
+    def test_list_collections_rich_output(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/collections",
+            json=[
+                {"uuid": uuid, "version": 1, "date": "2024-01-01T00:00:00Z", "artifacts": []},
+                {"uuid": uuid, "version": 2, "date": "2024-06-01T00:00:00Z", "artifacts": []},
+            ],
+        )
+        result = runner.invoke(app, ["list-collections", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert "Collections" in result.output
+
+    @responses.activate
+    def test_get_cle_product_release(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "released",
+                        "effective": "2024-01-15T00:00:00Z",
+                        "published": "2024-01-15T00:00:00Z",
+                        "version": "1.0.0",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(app, ["--json", "get-cle", uuid, "--entity", "product-release", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data["events"]) == 1
+        assert data["events"][0]["type"] == "released"
+
+    @responses.activate
+    def test_get_cle_product(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/product/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "endOfLife",
+                        "effective": "2025-12-31T00:00:00Z",
+                        "published": "2025-01-01T00:00:00Z",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(app, ["--json", "get-cle", uuid, "--entity", "product", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert data["events"][0]["type"] == "endOfLife"
+
+    @responses.activate
+    def test_get_cle_component(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/component/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "released",
+                        "effective": "2024-01-15T00:00:00Z",
+                        "published": "2024-01-15T00:00:00Z",
+                        "version": "1.0.0",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(app, ["--json", "get-cle", uuid, "--entity", "component", "--base-url", BASE_URL])
+        assert result.exit_code == 0
+
+    @responses.activate
+    def test_get_cle_component_release(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/componentRelease/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "released",
+                        "effective": "2024-01-15T00:00:00Z",
+                        "published": "2024-01-15T00:00:00Z",
+                        "version": "1.0.0",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(
+            app, ["--json", "get-cle", uuid, "--entity", "component-release", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 0
+
+    def test_get_cle_invalid_entity(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        result = runner.invoke(app, ["get-cle", uuid, "--entity", "invalid", "--base-url", BASE_URL])
+        assert result.exit_code == 1
+        assert "Invalid --entity" in result.output
+
+    @responses.activate
+    def test_get_cle_default_entity_is_product_release(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "released",
+                        "effective": "2024-01-15T00:00:00Z",
+                        "published": "2024-01-15T00:00:00Z",
+                        "version": "1.0.0",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(app, ["--json", "get-cle", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert len(data["events"]) == 1
+
+    @responses.activate
+    def test_get_cle_rich_output(self):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(
+            f"{BASE_URL}/productRelease/{uuid}/cle",
+            json={
+                "events": [
+                    {
+                        "id": 1,
+                        "type": "released",
+                        "effective": "2024-01-15T00:00:00Z",
+                        "published": "2024-01-15T00:00:00Z",
+                        "version": "1.0.0",
+                    }
+                ]
+            },
+        )
+        result = runner.invoke(app, ["get-cle", uuid, "--base-url", BASE_URL])
+        assert result.exit_code == 0
+        assert "Lifecycle Events" in result.output
+
+    def test_new_commands_in_help(self):
+        result = runner.invoke(app, ["--help"])
+        assert result.exit_code == 0
+        assert "get-product-releases" in result.output
+        assert "get-component" in result.output
+        assert "get-component-releases" in result.output
+        assert "list-collections" in result.output
+        assert "get-cle" in result.output
+
+
+class TestMtlsCli:
+    """Coverage for _build_mtls success and error paths."""
+
+    def test_cert_without_key_errors(self):
+        result = runner.invoke(
+            app,
+            [
+                "get-product",
+                "d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+                "--base-url",
+                BASE_URL,
+                "--client-cert",
+                "/tmp/cert.pem",
+            ],
+        )
+        assert result.exit_code == 1
+        assert "--client-key" in _strip_ansi(result.output)
+
+    def test_key_without_cert_errors(self):
+        result = runner.invoke(
+            app,
+            [
+                "get-product",
+                "d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+                "--base-url",
+                BASE_URL,
+                "--client-key",
+                "/tmp/key.pem",
+            ],
+        )
+        assert result.exit_code == 1
+        assert "--client-cert" in _strip_ansi(result.output)
+
+    @responses.activate
+    def test_both_cert_and_key_succeeds(self):
+        responses.get(
+            f"{BASE_URL}/product/d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+            json={"uuid": "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "name": "Test", "identifiers": []},
+        )
+        result = runner.invoke(
+            app,
+            [
+                "get-product",
+                "d4d9f54a-abcf-11ee-ac79-1a52914d44b1",
+                "--base-url",
+                BASE_URL,
+                "--client-cert",
+                "/tmp/cert.pem",
+                "--client-key",
+                "/tmp/key.pem",
+            ],
+        )
+        assert result.exit_code == 0
+
+
+class TestCLIErrorHandlingCoverage:
+    """Coverage for error paths on commands not yet tested for errors."""
+
+    @responses.activate
+    def test_get_product_releases_error(self):
+        responses.get(
+            f"{BASE_URL}/product/d4d9f54a-abcf-11ee-ac79-1a52914d44b1/releases",
+            status=404,
+            json={"error": "OBJECT_UNKNOWN"},
+        )
+        result = runner.invoke(
+            app, ["get-product-releases", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_component_error(self):
+        responses.get(
+            f"{BASE_URL}/component/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", status=404, json={"error": "OBJECT_UNKNOWN"}
+        )
+        result = runner.invoke(app, ["get-component", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_component_releases_error(self):
+        responses.get(
+            f"{BASE_URL}/component/d4d9f54a-abcf-11ee-ac79-1a52914d44b1/releases",
+            status=404,
+            json={"error": "OBJECT_UNKNOWN"},
+        )
+        result = runner.invoke(
+            app, ["get-component-releases", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_list_collections_error(self):
+        responses.get(
+            f"{BASE_URL}/productRelease/d4d9f54a-abcf-11ee-ac79-1a52914d44b1/collections",
+            status=404,
+            json={"error": "OBJECT_UNKNOWN"},
+        )
+        result = runner.invoke(
+            app, ["list-collections", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 1
+
+    @responses.activate
+    def test_get_cle_error(self):
+        responses.get(
+            f"{BASE_URL}/productRelease/d4d9f54a-abcf-11ee-ac79-1a52914d44b1/cle",
+            status=404,
+            json={"error": "OBJECT_UNKNOWN"},
+        )
+        result = runner.invoke(app, ["get-cle", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL])
+        assert result.exit_code == 1
+
+
+class TestDomainFromTeiCoverage:
+    """Coverage for _domain_from_tei exception path."""
+
+    def test_invalid_tei_falls_back_to_error(self):
+        result = runner.invoke(app, ["discover", "not-a-valid-tei"])
+        assert result.exit_code == 1
+
+
+class TestJsonListOutput:
+    """Coverage for _output JSON list branch."""
+
+    @responses.activate
+    def test_component_releases_json_list(self):
+        responses.get(
+            f"{BASE_URL}/component/d4d9f54a-abcf-11ee-ac79-1a52914d44b1/releases",
+            json=[
+                {
+                    "uuid": "e5e0a65b-bcdf-22ff-bd80-2b63a25e55c2",
+                    "version": "1.0.0",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                }
+            ],
+        )
+        result = runner.invoke(
+            app, ["--json", "get-component-releases", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", BASE_URL]
+        )
+        assert result.exit_code == 0
+        data = json.loads(result.output)
+        assert isinstance(data, list)
+        assert data[0]["uuid"] == "e5e0a65b-bcdf-22ff-bd80-2b63a25e55c2"
+
+
+class TestCliEntryImportError:
+    """Coverage for _cli_entry.py ImportError branch."""
+
+    def test_missing_typer_prints_install_hint(self):
+        import subprocess
+        import sys
+
+        result = subprocess.run(
+            [
+                sys.executable,
+                "-c",
+                "import sys; sys.modules['typer'] = None; from libtea._cli_entry import main; main()",
+            ],
+            capture_output=True,
+            text=True,
+        )
+        # The import error handling results in SystemExit(1)
+        assert result.returncode == 1
diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py
new file mode 100644
index 0000000..0737a62
--- /dev/null
+++ b/tests/cli/test_cli_fmt.py
@@ -0,0 +1,953 @@
+"""Unit tests for libtea._cli_fmt rich formatters."""
+
+from io import StringIO
+
+import pytest
+
+typer = pytest.importorskip("typer", reason="typer not installed (install libtea[cli])")
+
+from rich.console import Console  # noqa: E402
+
+from libtea._cli_fmt import (  # noqa: E402
+    _fmt_identifiers,
+    _opt,
+    fmt_artifact,
+    fmt_cle,
+    fmt_collection,
+    fmt_collections,
+    fmt_component,
+    fmt_component_release,
+    fmt_discover,
+    fmt_inspect,
+    fmt_product,
+    fmt_product_release,
+    fmt_releases,
+    fmt_search_products,
+    fmt_search_releases,
+    format_output,
+)
+from libtea.models import (  # noqa: E402
+    CLE,
+    Artifact,
+    ArtifactFormat,
+    Checksum,
+    ChecksumAlgorithm,
+    CLEDefinitions,
+    CLEEvent,
+    CLEEventType,
+    CLESupportDefinition,
+    CLEVersionSpecifier,
+    Collection,
+    CollectionBelongsTo,
+    CollectionUpdateReason,
+    CollectionUpdateReasonType,
+    Component,
+    ComponentReleaseWithCollection,
+    DiscoveryInfo,
+    Identifier,
+    PaginatedProductReleaseResponse,
+    PaginatedProductResponse,
+    Product,
+    ProductRelease,
+    Release,
+    ReleaseDistribution,
+    TeaServerInfo,
+)
+
+
+def _capture(fn, *args, **kwargs) -> str:
+    """Call a formatter with a StringIO-backed Console and return the output."""
+    buf = StringIO()
+    console = Console(file=buf, force_terminal=True, width=200)
+    fn(*args, console=console, **kwargs)
+    return buf.getvalue()
+
+
+# --- Helper tests ---
+
+
+class TestHelpers:
+    def test_opt_none(self):
+        assert _opt(None) == "-"
+
+    def test_opt_value(self):
+        assert _opt("hello") == "hello"
+
+    def test_opt_int(self):
+        assert _opt(42) == "42"
+
+    def test_fmt_identifiers_empty(self):
+        assert _fmt_identifiers([]) == "-"
+
+    def test_fmt_identifiers_single(self):
+        idents = [Identifier(id_type="PURL", id_value="pkg:pypi/test")]
+        assert _fmt_identifiers(idents) == "PURL:pkg:pypi/test"
+
+    def test_fmt_identifiers_multiple(self):
+        idents = [
+            Identifier(id_type="PURL", id_value="pkg:pypi/test"),
+            Identifier(id_type="CPE", id_value="cpe:2.3:a:test"),
+        ]
+        result = _fmt_identifiers(idents)
+        assert "PURL:pkg:pypi/test" in result
+        assert "CPE:cpe:2.3:a:test" in result
+
+
+# --- Formatter tests ---
+
+UUID = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+UUID2 = "e5e0a65b-bcdf-22ff-bd80-2b63a25e55c2"
+
+
+class TestFmtDiscover:
+    def test_renders_table(self):
+        data = [
+            DiscoveryInfo(
+                product_release_uuid=UUID,
+                servers=[TeaServerInfo(root_url="https://tea.example.com", versions=["1.0.0"], priority=0.8)],
+            )
+        ]
+        output = _capture(fmt_discover, data)
+        assert "Discovery Results" in output
+        assert UUID in output
+        assert "https://tea.example.com" in output
+        assert "0.8" in output
+
+    def test_empty_list_renders_empty_table(self):
+        output = _capture(fmt_discover, [])
+        assert "Discovery Results" in output
+
+
+class TestFmtSearchProducts:
+    def test_renders_pagination_and_table(self):
+        data = PaginatedProductResponse(
+            timestamp="2024-01-01T00:00:00Z",
+            page_start_index=0,
+            page_size=100,
+            total_results=1,
+            results=[Product(uuid=UUID, name="Test Product", identifiers=[])],
+        )
+        output = _capture(fmt_search_products, data)
+        assert "Results 1-1 of 1" in output
+        assert "Test Product" in output
+
+    def test_empty_results(self):
+        data = PaginatedProductResponse(
+            timestamp="2024-01-01T00:00:00Z",
+            page_start_index=0,
+            page_size=100,
+            total_results=0,
+            results=[],
+        )
+        output = _capture(fmt_search_products, data)
+        assert "No results (total: 0)" in output
+
+
+class TestFmtSearchReleases:
+    def test_renders_table(self):
+        data = 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
+                )
+            ],
+        )
+        output = _capture(fmt_search_releases, data)
+        assert "Product Releases" in output
+        assert "1.0.0" in output
+        assert "True" in output
+
+
+class TestFmtProduct:
+    def test_renders_panel(self):
+        product = Product(
+            uuid=UUID,
+            name="Test Product",
+            identifiers=[Identifier(id_type="PURL", id_value="pkg:pypi/test")],
+        )
+        output = _capture(fmt_product, product)
+        assert "Product" in output
+        assert UUID in output
+        assert "Test Product" in output
+        assert "PURL:pkg:pypi/test" in output
+
+    def test_markup_escape(self):
+        """Server-controlled data with Rich markup chars is escaped."""
+        product = Product(uuid=UUID, name="[bold red]Evil[/bold red]", identifiers=[])
+        output = _capture(fmt_product, product)
+        # The markup should be escaped, not rendered as bold/red
+        assert "[bold red]" in output or "Evil" in output
+        assert UUID in output
+
+
+class TestFmtProductRelease:
+    def test_renders_panel_and_components(self):
+        data = ProductRelease(
+            uuid=UUID,
+            version="2.0.0",
+            product_name="My Product",
+            created_date="2024-01-01T00:00:00Z",
+            release_date="2024-01-15T00:00:00Z",
+            pre_release=False,
+            identifiers=[],
+            components=[{"uuid": UUID2, "release": UUID2}],
+        )
+        output = _capture(fmt_product_release, data)
+        assert "Product Release" in output
+        assert "2.0.0" in output
+        assert "My Product" in output
+        assert "Components" in output
+        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=[])
+        output = _capture(fmt_product_release, data)
+        assert "Product Release" in output
+        assert "Components" not in output
+
+
+class TestFmtComponentRelease:
+    def test_renders_release_and_collection(self):
+        data = ComponentReleaseWithCollection(
+            release=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=[]),
+        )
+        output = _capture(fmt_component_release, data)
+        assert "Component Release" in output
+        assert "libfoo" in output
+        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(
+                uuid=UUID,
+                version=1,
+                artifacts=[Artifact(uuid=UUID2, name="SBOM", type="BOM", formats=[])],
+            ),
+        )
+        output = _capture(fmt_component_release, data)
+        assert "Artifacts" in output
+        assert "SBOM" in output
+
+
+class TestFmtCollection:
+    def test_renders_panel(self):
+        data = Collection(
+            uuid=UUID,
+            version=3,
+            belongs_to=CollectionBelongsTo.PRODUCT_RELEASE,
+            update_reason=CollectionUpdateReason(
+                type=CollectionUpdateReasonType.VEX_UPDATED, comment="CVE-2024-1234 fixed"
+            ),
+            artifacts=[],
+        )
+        output = _capture(fmt_collection, data)
+        assert "Collection" in output
+        assert "PRODUCT_RELEASE" in output
+        assert "VEX_UPDATED" in output
+        assert "CVE-2024-1234 fixed" in output
+
+    def test_no_update_reason(self):
+        data = 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(
+            uuid=UUID,
+            name="SBOM",
+            type="BOM",
+            formats=[
+                ArtifactFormat(
+                    media_type="application/json",
+                    url="https://cdn.example.com/sbom.json",
+                    checksums=[Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abcdef1234567890")],
+                )
+            ],
+        )
+        output = _capture(fmt_artifact, data)
+        assert "Artifact" in output
+        assert "SBOM" in output
+        assert "BOM" in output
+        assert "Formats" in output
+        assert "application/json" in output
+        assert "abcdef123456" in output
+
+    def test_no_formats(self):
+        data = Artifact(uuid=UUID, name="VEX", type="VULNERABILITIES", formats=[])
+        output = _capture(fmt_artifact, data)
+        assert "Artifact" in output
+        assert "Formats" not in output
+
+
+class TestFmtInspect:
+    def test_renders_release_and_components(self):
+        data = [
+            {
+                "discovery": {
+                    "productReleaseUuid": UUID,
+                    "servers": [
+                        {"rootUrl": "https://tea.example.com", "versions": ["0.3.0-beta.2"], "priority": 1.0},
+                    ],
+                },
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"},
+                "components": [
+                    {"uuid": UUID2, "version": "2.0.0", "name": "libbar"},
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "Discovery Servers" in output
+        assert "tea.example.com" in output
+        assert "0.3.0-beta.2" in output
+        assert "Product Release" in output
+        assert UUID in output
+        assert "Components" in output
+        assert "libbar" in output
+
+    def test_renders_all_product_release_fields(self):
+        """Panel should show product name, release date, pre-release, and identifiers."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {
+                    "uuid": UUID,
+                    "version": "2.0.0",
+                    "productName": "My Product",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                    "releaseDate": "2024-01-15T00:00:00Z",
+                    "preRelease": False,
+                    "identifiers": [
+                        {"idType": "PURL", "idValue": "pkg:pypi/test"},
+                        {"idType": "ASIN", "idValue": "B07FDJMC9Q"},
+                    ],
+                },
+                "components": [],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "My Product" in output
+        assert "2024-01-15" in output
+        assert "False" in output
+        assert "PURL:pkg:pypi/test" in output
+        assert "ASIN:B07FDJMC9Q" in output
+
+    def test_truncated_output(self):
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [{"uuid": UUID2, "version": "1.0.0", "name": "comp1"}],
+                "truncated": True,
+                "totalComponents": 50,
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "1 of 50" in output
+
+    def test_empty_components(self):
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "Product Release" in output
+        assert "Components" not in output
+
+    def test_component_with_nested_release(self):
+        """Component data that comes from componentRelease endpoint (nested release dict)."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {"release": {"uuid": UUID2, "version": "3.0.0", "componentName": "nested-comp"}},
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert UUID2 in output
+        assert "3.0.0" in output
+
+    def test_resolved_unpinned_component_with_artifacts(self):
+        """Unpinned component with a resolved release should show artifacts."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {
+                        "uuid": UUID2,
+                        "name": "App",
+                        "identifiers": [],
+                        "resolvedNote": "latest release (not pinned)",
+                        "resolvedRelease": {
+                            "release": {"uuid": UUID2, "version": "1.0.0", "createdDate": "2024-01-01"},
+                            "latestCollection": {
+                                "uuid": UUID,
+                                "version": 1,
+                                "artifacts": [
+                                    {
+                                        "uuid": UUID2,
+                                        "name": "SPDX SBOM",
+                                        "type": "BOM",
+                                        "formats": [
+                                            {
+                                                "mediaType": "application/spdx+json",
+                                                "url": "https://cdn.example.com/sbom.json",
+                                                "checksums": [],
+                                            }
+                                        ],
+                                    }
+                                ],
+                            },
+                        },
+                    }
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "App" in output
+        assert "latest release (not pinned)" in output
+        assert "Artifacts" in output
+        assert "SPDX SBOM" in output
+        assert "BOM" in output
+        assert "application/spdx+json" in output
+        assert "https://cdn.example.com/sbom.json" in output
+
+    def test_pinned_component_with_collection_artifacts(self):
+        """Pinned component (from componentRelease) should show artifacts from latestCollection."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {
+                        "release": {"uuid": UUID2, "version": "2.0.0", "componentName": "libfoo"},
+                        "latestCollection": {
+                            "uuid": UUID,
+                            "version": 1,
+                            "artifacts": [
+                                {
+                                    "uuid": UUID2,
+                                    "name": "VEX",
+                                    "type": "VULNERABILITIES",
+                                    "formats": [{"mediaType": "application/json", "url": "https://cdn/vex.json"}],
+                                }
+                            ],
+                        },
+                    }
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "libfoo" in output
+        assert "VEX" in output
+        assert "application/json" in output
+
+
+class TestFormatOutputDispatch:
+    def test_dispatch_product(self):
+        product = Product(uuid=UUID, name="Test", identifiers=[])
+        output = _capture(format_output, product)
+        assert "Product" in output
+
+    def test_dispatch_discover_via_command(self):
+        data = [
+            DiscoveryInfo(
+                product_release_uuid=UUID,
+                servers=[TeaServerInfo(root_url="https://tea.example.com", versions=["1.0.0"])],
+            )
+        ]
+        output = _capture(format_output, data, command="discover")
+        assert "Discovery Results" in output
+
+    def test_dispatch_empty_discover_via_command(self):
+        """Empty discovery list should still render a table, not fall through to JSON."""
+        output = _capture(format_output, [], command="discover")
+        assert "Discovery Results" in output
+
+    def test_dispatch_inspect_via_command(self):
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [],
+            }
+        ]
+        output = _capture(format_output, data, command="inspect")
+        assert "Product Release" in output
+
+    def test_fallback_renders_json(self):
+        """Unknown types fall back to JSON rendering."""
+        output = _capture(format_output, {"foo": "bar"})
+        assert "foo" in output
+        assert "bar" in output
+
+
+class TestMarkupEscape:
+    """Verify that server-controlled data with Rich markup is safely escaped."""
+
+    def test_panel_escapes_markup_in_value(self):
+        product = Product(uuid=UUID, name="[bold]bad[/bold]", identifiers=[])
+        output = _capture(fmt_product, product)
+        # Should not crash, and should contain the literal brackets
+        assert UUID in output
+
+    def test_panel_escapes_markup_in_identifiers(self):
+        product = Product(
+            uuid=UUID,
+            name="safe",
+            identifiers=[Identifier(id_type="PURL", id_value="[link=http://evil]click[/link]")],
+        )
+        output = _capture(fmt_product, product)
+        assert "safe" in output
+
+
+class TestDistributionsTable:
+    """Test display of release-distribution fields."""
+
+    def test_component_release_with_distributions(self):
+        data = ComponentReleaseWithCollection(
+            release=Release(
+                uuid=UUID,
+                version="11.0.7",
+                component_name="tomcat",
+                created_date="2024-01-01T00:00:00Z",
+                distributions=[
+                    ReleaseDistribution(
+                        distribution_type="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",
+                        checksums=[
+                            Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abcdef1234567890")
+                        ],
+                    ),
+                    ReleaseDistribution(
+                        distribution_type="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=[]),
+        )
+        output = _capture(fmt_component_release, data)
+        assert "Distributions" in output
+        assert "zip" in output
+        assert "tar.gz" in output
+        assert "Core binary distribution, zip archive" in output
+        assert "https://repo.example.com/tomcat-11.0.7.zip" in output
+        assert "tomcat-11.0.7.zip.asc" in output
+        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=[]),
+        )
+        output = _capture(fmt_component_release, data)
+        assert "Distributions" not in output
+
+    def test_inspect_component_with_distributions(self):
+        """Inspect output should show distributions from the release dict."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {
+                        "release": {
+                            "uuid": UUID2,
+                            "version": "11.0.7",
+                            "componentName": "tomcat",
+                            "distributions": [
+                                {
+                                    "distributionType": "zip",
+                                    "description": "Zip archive",
+                                    "url": "https://repo.example.com/tomcat.zip",
+                                    "signatureUrl": "https://repo.example.com/tomcat.zip.asc",
+                                    "checksums": [{"algType": "SHA-256", "algValue": "abc123def456"}],
+                                }
+                            ],
+                        },
+                        "latestCollection": {"uuid": UUID, "version": 1, "artifacts": []},
+                    }
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "Distributions" in output
+        assert "zip" in output
+        assert "Zip archive" in output
+        assert "https://repo.example.com/tomcat.zip" in output
+        assert "tomcat.zip.asc" in output
+        assert "SHA-256:abc123def456" in output
+
+
+class TestArtifactFormatDetails:
+    """Test display of artifact-format description, signatureUrl, and distributionTypes."""
+
+    def test_formats_table_shows_description_and_signature(self):
+        data = Artifact(
+            uuid=UUID,
+            name="Build SBOM",
+            type="BOM",
+            formats=[
+                ArtifactFormat(
+                    media_type="application/vnd.cyclonedx+xml",
+                    description="CycloneDX SBOM (XML)",
+                    url="https://repo.example.com/sbom.xml",
+                    signature_url="https://repo.example.com/sbom.xml.asc",
+                    checksums=[Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abcdef1234567890")],
+                )
+            ],
+        )
+        output = _capture(fmt_artifact, data)
+        assert "CycloneDX SBOM (XML)" in output
+        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_inspect_artifact_shows_description_and_signature(self):
+        """Inspect output should show description and signatureUrl for artifact formats."""
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {
+                        "uuid": UUID2,
+                        "name": "App",
+                        "resolvedRelease": {
+                            "release": {"uuid": UUID2, "version": "1.0.0", "createdDate": "2024-01-01"},
+                            "latestCollection": {
+                                "uuid": UUID,
+                                "version": 1,
+                                "artifacts": [
+                                    {
+                                        "uuid": UUID2,
+                                        "name": "VDR",
+                                        "type": "VULNERABILITIES",
+                                        "distributionTypes": ["zip"],
+                                        "formats": [
+                                            {
+                                                "mediaType": "application/vnd.cyclonedx+xml",
+                                                "description": "CycloneDX VDR (XML)",
+                                                "url": "https://example.com/vdr.xml",
+                                                "signatureUrl": "https://example.com/vdr.xml.asc",
+                                            }
+                                        ],
+                                    }
+                                ],
+                            },
+                        },
+                    }
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "CycloneDX VDR (XML)" in output
+        assert "vdr.xml.asc" in output
+        assert "zip" in output
+
+
+class TestComponentFormatter:
+    def test_fmt_component(self):
+        comp = Component(
+            uuid=UUID,
+            name="My Component",
+            identifiers=[Identifier(id_type="PURL", id_value="pkg:pypi/test")],
+        )
+        output = _capture(fmt_component, comp)
+        assert UUID in output
+        assert "My Component" in output
+        assert "PURL:pkg:pypi/test" in output
+
+    def test_format_output_dispatches_component(self):
+        comp = Component(uuid=UUID, name="Comp", identifiers=[])
+        output = _capture(format_output, comp)
+        assert "Comp" in output
+
+
+class TestReleasesFormatter:
+    def test_fmt_releases(self):
+        releases = [
+            Release(
+                uuid=UUID,
+                version="1.0.0",
+                component_name="App",
+                created_date="2024-01-01T00:00:00Z",
+                release_date="2024-02-01T00:00:00Z",
+                pre_release=False,
+            ),
+            Release(
+                uuid=UUID2,
+                version="2.0.0",
+                component_name="App",
+                created_date="2024-06-01T00:00:00Z",
+            ),
+        ]
+        output = _capture(fmt_releases, releases)
+        assert "Component Releases" in output
+        assert "1.0.0" in output
+        assert "2.0.0" in output
+        assert "App" in output
+
+    def test_format_output_releases_command(self):
+        releases = [
+            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
+        assert "1.0.0" in output
+
+
+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(
+                uuid=UUID2,
+                version=2,
+                date="2024-06-01T00:00:00Z",
+                belongs_to="COMPONENT_RELEASE",
+                artifacts=[Artifact(uuid=UUID, name="SBOM", type="BOM", formats=[])],
+            ),
+        ]
+        output = _capture(fmt_collections, cols)
+        assert "Collections" in output
+        assert UUID in output
+        assert UUID2 in output
+        # Second collection has 1 artifact
+        assert "1" in output
+
+    def test_format_output_collections_command(self):
+        cols = [Collection(uuid=UUID, version=1, artifacts=[])]
+        output = _capture(format_output, cols, command="collections")
+        assert "Collections" in output
+
+
+class TestCLEFormatter:
+    def test_fmt_cle_basic(self):
+        cle = CLE(
+            events=[
+                CLEEvent(
+                    id=1,
+                    type=CLEEventType.RELEASED,
+                    effective="2024-01-15T00:00:00Z",
+                    published="2024-01-15T00:00:00Z",
+                    version="1.0.0",
+                    license="Apache-2.0",
+                ),
+                CLEEvent(
+                    id=2,
+                    type=CLEEventType.END_OF_SUPPORT,
+                    effective="2025-01-15T00:00:00Z",
+                    published="2024-06-01T00:00:00Z",
+                    support_id="standard",
+                    reason="EOL",
+                ),
+            ]
+        )
+        output = _capture(fmt_cle, cle)
+        assert "Lifecycle Events" in output
+        assert "released" in output
+        assert "endOfSupport" in output
+        assert "1.0.0" in output
+        assert "license=Apache-2.0" in output
+        assert "support=standard" in output
+        assert "reason=EOL" in output
+
+    def test_fmt_cle_with_definitions(self):
+        cle = CLE(
+            definitions=CLEDefinitions(
+                support=[
+                    CLESupportDefinition(
+                        id="standard", description="Standard support", url="https://example.com/support"
+                    ),
+                ]
+            ),
+            events=[
+                CLEEvent(
+                    id=1,
+                    type=CLEEventType.RELEASED,
+                    effective="2024-01-15T00:00:00Z",
+                    published="2024-01-15T00:00:00Z",
+                    version="1.0.0",
+                ),
+            ],
+        )
+        output = _capture(fmt_cle, cle)
+        assert "Support Definitions" in output
+        assert "standard" in output
+        assert "Standard support" in output
+        assert "example.com/support" in output
+
+    def test_fmt_cle_superseded(self):
+        cle = CLE(
+            events=[
+                CLEEvent(
+                    id=1,
+                    type=CLEEventType.SUPERSEDED_BY,
+                    effective="2024-01-15T00:00:00Z",
+                    published="2024-01-15T00:00:00Z",
+                    superseded_by_version="2.0.0",
+                ),
+            ]
+        )
+        output = _capture(fmt_cle, cle)
+        assert "supersededBy" in output
+        assert "superseded_by=2.0.0" in output
+
+    def test_format_output_dispatches_cle(self):
+        cle = CLE(
+            events=[
+                CLEEvent(
+                    id=1,
+                    type=CLEEventType.RELEASED,
+                    effective="2024-01-15T00:00:00Z",
+                    published="2024-01-15T00:00:00Z",
+                    version="1.0.0",
+                ),
+            ]
+        )
+        output = _capture(format_output, cle)
+        assert "Lifecycle Events" in output
+
+
+class TestCollectionUpdateReasonComment:
+    """Cover the update_reason.comment branch at _cli_fmt.py:239."""
+
+    def test_update_reason_without_comment(self):
+        data = Collection(
+            uuid=UUID,
+            version=2,
+            update_reason=CollectionUpdateReason(type=CollectionUpdateReasonType.VEX_UPDATED),
+            artifacts=[],
+        )
+        output = _capture(fmt_collection, data)
+        assert "VEX_UPDATED" in output
+        # No parenthesised comment
+        assert "(" not in output.split("VEX_UPDATED")[1].split("\n")[0]
+
+
+class TestCLEEventIdAndVersions:
+    """Cover event_id (line 345) and versions (lines 349-350) branches."""
+
+    def test_event_id_is_shown(self):
+        cle = CLE(
+            events=[
+                CLEEvent(
+                    id=2,
+                    type=CLEEventType.WITHDRAWN,
+                    effective="2024-06-01T00:00:00Z",
+                    published="2024-06-01T00:00:00Z",
+                    event_id=1,
+                ),
+            ]
+        )
+        output = _capture(fmt_cle, cle)
+        assert "event_id=1" in output
+
+    def test_versions_shown_as_range(self):
+        cle = CLE(
+            events=[
+                CLEEvent(
+                    id=1,
+                    type=CLEEventType.END_OF_SUPPORT,
+                    effective="2024-06-01T00:00:00Z",
+                    published="2024-06-01T00:00:00Z",
+                    versions=[
+                        CLEVersionSpecifier(version="1.0.0"),
+                        CLEVersionSpecifier(range="vers:semver/>=2.0.0|<3.0.0"),
+                    ],
+                ),
+            ]
+        )
+        output = _capture(fmt_cle, cle)
+        assert "1.0.0" in output
+        assert "vers:semver/>=2.0.0|<3.0.0" in output
+
+
+class TestInspectArtifactWithoutFormats:
+    """Cover the else branch at _cli_fmt.py:460 (artifact with no formats)."""
+
+    def test_artifact_no_formats_in_inspect(self):
+        data = [
+            {
+                "discovery": {"productReleaseUuid": UUID},
+                "productRelease": {"uuid": UUID, "version": "1.0.0", "createdDate": "2024-01-01"},
+                "components": [
+                    {
+                        "release": {"uuid": UUID2, "version": "2.0.0", "componentName": "libfoo"},
+                        "latestCollection": {
+                            "uuid": UUID,
+                            "version": 1,
+                            "artifacts": [
+                                {"uuid": UUID2, "name": "VEX", "type": "VULNERABILITIES", "formats": []},
+                            ],
+                        },
+                    }
+                ],
+            }
+        ]
+        output = _capture(fmt_inspect, data)
+        assert "VEX" in output
+
+
+class TestFormatOutputFallbacks:
+    """Cover JSON fallback branches at _cli_fmt.py:517-521."""
+
+    def test_fallback_basemodel_json(self):
+        """BaseModel not in _TYPE_FORMATTERS renders as JSON (line 518)."""
+        from libtea.models import TeaEndpoint
+
+        ep = TeaEndpoint(url="https://tea.example.com", versions=["1.0.0"])
+        output = _capture(format_output, ep)
+        assert "tea.example.com" in output
+
+    def test_fallback_list_json(self):
+        """List of BaseModels with unknown command renders as JSON (lines 519-521)."""
+        from libtea.models import TeaEndpoint
+
+        eps = [
+            TeaEndpoint(url="https://tea1.example.com", versions=["1.0.0"]),
+            TeaEndpoint(url="https://tea2.example.com", versions=["2.0.0"]),
+        ]
+        output = _capture(format_output, eps, command="unknown_command")
+        assert "tea1.example.com" in output
+        assert "tea2.example.com" in output
diff --git a/tests/client/__init__.py b/tests/client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
new file mode 100644
index 0000000..f2ba756
--- /dev/null
+++ b/tests/client/test_client.py
@@ -0,0 +1,758 @@
+from pathlib import Path
+
+import pytest
+import requests
+import responses
+
+from libtea._http import MtlsConfig, probe_endpoint
+from libtea.client import TeaClient
+from libtea.exceptions import TeaConnectionError, TeaDiscoveryError, TeaServerError, TeaValidationError
+from libtea.models import (
+    CLE,
+    Artifact,
+    Checksum,
+    ChecksumAlgorithm,
+    Collection,
+    Component,
+    ComponentReleaseWithCollection,
+    PaginatedProductReleaseResponse,
+    PaginatedProductResponse,
+    Product,
+    ProductRelease,
+    Release,
+)
+
+
+class TestSearchProducts:
+    @responses.activate
+    def test_search_products_by_purl(self, client, base_url):
+        responses.get(
+            f"{base_url}/products",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 1,
+                "results": [
+                    {
+                        "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+                        "name": "Test Product",
+                        "identifiers": [{"idType": "PURL", "idValue": "pkg:pypi/foo"}],
+                    },
+                ],
+            },
+        )
+        resp = client.search_products("PURL", "pkg:pypi/foo")
+        assert isinstance(resp, PaginatedProductResponse)
+        assert resp.total_results == 1
+        assert resp.results[0].name == "Test Product"
+        request = responses.calls[0].request
+        assert "idType=PURL" in str(request.url)
+        assert "idValue=pkg" in str(request.url)
+
+    @responses.activate
+    def test_search_products_pagination(self, client, base_url):
+        responses.get(
+            f"{base_url}/products",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 10,
+                "pageSize": 25,
+                "totalResults": 50,
+                "results": [],
+            },
+        )
+        resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25)
+        request = responses.calls[0].request
+        assert "pageOffset=10" in str(request.url)
+        assert "pageSize=25" in str(request.url)
+        assert resp.page_start_index == 10
+
+    @responses.activate
+    def test_search_products_empty(self, client, base_url):
+        responses.get(
+            f"{base_url}/products",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 0,
+                "results": [],
+            },
+        )
+        resp = client.search_products("PURL", "pkg:pypi/nonexistent")
+        assert resp.total_results == 0
+        assert resp.results == ()
+
+
+class TestSearchProductReleases:
+    @responses.activate
+    def test_search_product_releases_by_purl(self, client, base_url):
+        responses.get(
+            f"{base_url}/productReleases",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 1,
+                "results": [
+                    {
+                        "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+                        "version": "1.0.0",
+                        "createdDate": "2024-01-01T00:00:00Z",
+                        "components": [{"uuid": "c3d4e5f6-a7b8-9012-cdef-123456789012"}],
+                    }
+                ],
+            },
+        )
+        resp = client.search_product_releases("PURL", "pkg:pypi/foo@1.0.0")
+        assert isinstance(resp, PaginatedProductReleaseResponse)
+        assert resp.total_results == 1
+        assert resp.results[0].version == "1.0.0"
+        request = responses.calls[0].request
+        assert "idType=PURL" in str(request.url)
+
+
+class TestProduct:
+    @responses.activate
+    def test_get_product(self, client, base_url):
+        responses.get(
+            f"{base_url}/product/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+            json={
+                "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+                "name": "Test Product",
+                "identifiers": [{"idType": "PURL", "idValue": "pkg:npm/test"}],
+            },
+        )
+        product = client.get_product("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
+        assert isinstance(product, Product)
+        assert product.name == "Test Product"
+
+    @responses.activate
+    def test_get_product_releases(self, client, base_url):
+        responses.get(
+            f"{base_url}/product/a1b2c3d4-e5f6-7890-abcd-ef1234567890/releases",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 0,
+                "pageSize": 100,
+                "totalResults": 1,
+                "results": [
+                    {
+                        "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+                        "version": "1.0.0",
+                        "createdDate": "2024-01-01T00:00:00Z",
+                        "components": [{"uuid": "c3d4e5f6-a7b8-9012-cdef-123456789012"}],
+                    }
+                ],
+            },
+        )
+        resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
+        assert isinstance(resp, PaginatedProductReleaseResponse)
+        assert resp.total_results == 1
+
+
+class TestProductRelease:
+    @responses.activate
+    def test_get_product_release(self, client, base_url):
+        responses.get(
+            f"{base_url}/productRelease/b2c3d4e5-f6a7-8901-bcde-f12345678901",
+            json={
+                "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+                "version": "1.0.0",
+                "createdDate": "2024-01-01T00:00:00Z",
+                "components": [{"uuid": "c3d4e5f6-a7b8-9012-cdef-123456789012"}],
+            },
+        )
+        release = client.get_product_release("b2c3d4e5-f6a7-8901-bcde-f12345678901")
+        assert isinstance(release, ProductRelease)
+        assert release.version == "1.0.0"
+
+    @responses.activate
+    def test_get_product_release_collection_latest(self, client, base_url):
+        responses.get(
+            f"{base_url}/productRelease/b2c3d4e5-f6a7-8901-bcde-f12345678901/collection/latest",
+            json={
+                "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+                "version": 1,
+                "artifacts": [],
+            },
+        )
+        collection = client.get_product_release_collection_latest("b2c3d4e5-f6a7-8901-bcde-f12345678901")
+        assert isinstance(collection, Collection)
+
+
+class TestComponent:
+    @responses.activate
+    def test_get_component(self, client, base_url):
+        responses.get(
+            f"{base_url}/component/c3d4e5f6-a7b8-9012-cdef-123456789012",
+            json={
+                "uuid": "c3d4e5f6-a7b8-9012-cdef-123456789012",
+                "name": "Test Component",
+                "identifiers": [],
+            },
+        )
+        component = client.get_component("c3d4e5f6-a7b8-9012-cdef-123456789012")
+        assert isinstance(component, Component)
+        assert component.name == "Test Component"
+
+    @responses.activate
+    def test_get_component_releases(self, client, base_url):
+        responses.get(
+            f"{base_url}/component/c3d4e5f6-a7b8-9012-cdef-123456789012/releases",
+            json=[
+                {
+                    "uuid": "d4e5f6a7-b8c9-0123-defa-234567890123",
+                    "version": "1.0.0",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                },
+            ],
+        )
+        releases = client.get_component_releases("c3d4e5f6-a7b8-9012-cdef-123456789012")
+        assert len(releases) == 1
+        assert isinstance(releases[0], Release)
+
+
+class TestComponentRelease:
+    @responses.activate
+    def test_get_component_release(self, client, base_url):
+        responses.get(
+            f"{base_url}/componentRelease/d4e5f6a7-b8c9-0123-defa-234567890123",
+            json={
+                "release": {
+                    "uuid": "d4e5f6a7-b8c9-0123-defa-234567890123",
+                    "version": "1.0.0",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                },
+                "latestCollection": {"uuid": "d4e5f6a7-b8c9-0123-defa-234567890123", "version": 1, "artifacts": []},
+            },
+        )
+        result = client.get_component_release("d4e5f6a7-b8c9-0123-defa-234567890123")
+        assert isinstance(result, ComponentReleaseWithCollection)
+        assert result.release.version == "1.0.0"
+        assert result.latest_collection is not None
+
+    @responses.activate
+    def test_get_component_release_missing_collection_raises(self, client, base_url):
+        """Per TEA spec, latestCollection is required — missing it should raise."""
+        responses.get(
+            f"{base_url}/componentRelease/d4e5f6a7-b8c9-0123-defa-234567890124",
+            json={
+                "release": {
+                    "uuid": "d4e5f6a7-b8c9-0123-defa-234567890124",
+                    "version": "2.0.0",
+                    "createdDate": "2024-01-01T00:00:00Z",
+                },
+            },
+        )
+        with pytest.raises(TeaValidationError, match="Invalid ComponentReleaseWithCollection"):
+            client.get_component_release("d4e5f6a7-b8c9-0123-defa-234567890124")
+
+    @responses.activate
+    def test_get_component_release_collection_latest(self, client, base_url):
+        responses.get(
+            f"{base_url}/componentRelease/d4e5f6a7-b8c9-0123-defa-234567890123/collection/latest",
+            json={"uuid": "d4e5f6a7-b8c9-0123-defa-234567890123", "version": 2, "artifacts": []},
+        )
+        collection = client.get_component_release_collection_latest("d4e5f6a7-b8c9-0123-defa-234567890123")
+        assert isinstance(collection, Collection)
+        assert collection.version == 2
+
+    @responses.activate
+    def test_get_component_release_collections(self, client, base_url):
+        responses.get(
+            f"{base_url}/componentRelease/d4e5f6a7-b8c9-0123-defa-234567890123/collections",
+            json=[
+                {"uuid": "d4e5f6a7-b8c9-0123-defa-234567890123", "version": 1, "artifacts": []},
+                {"uuid": "d4e5f6a7-b8c9-0123-defa-234567890123", "version": 2, "artifacts": []},
+            ],
+        )
+        collections = client.get_component_release_collections("d4e5f6a7-b8c9-0123-defa-234567890123")
+        assert len(collections) == 2
+
+    @responses.activate
+    def test_get_component_release_collection_by_version(self, client, base_url):
+        responses.get(
+            f"{base_url}/componentRelease/d4e5f6a7-b8c9-0123-defa-234567890123/collection/3",
+            json={"uuid": "d4e5f6a7-b8c9-0123-defa-234567890123", "version": 3, "artifacts": []},
+        )
+        collection = client.get_component_release_collection("d4e5f6a7-b8c9-0123-defa-234567890123", 3)
+        assert collection.version == 3
+
+
+class TestArtifact:
+    @responses.activate
+    def test_get_artifact(self, client, base_url):
+        responses.get(
+            f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234",
+            json={
+                "uuid": "e5f6a7b8-c9d0-1234-efab-345678901234",
+                "name": "SBOM",
+                "type": "BOM",
+                "formats": [
+                    {
+                        "mediaType": "application/json",
+                        "url": "https://example.com/sbom.json",
+                        "checksums": [],
+                    }
+                ],
+            },
+        )
+        artifact = client.get_artifact("e5f6a7b8-c9d0-1234-efab-345678901234")
+        assert isinstance(artifact, Artifact)
+        assert artifact.name == "SBOM"
+
+
+class TestDiscovery:
+    @responses.activate
+    def test_discover(self, client, base_url):
+        tei = "urn:tei:uuid:example.com:d4d9f54a-abcf-11ee-ac79-1a52914d44b"
+        responses.get(
+            f"{base_url}/discovery",
+            json=[
+                {
+                    "productReleaseUuid": "d4d9f54a-abcf-11ee-ac79-1a52914d44b",
+                    "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}],
+                }
+            ],
+        )
+        results = client.discover(tei)
+        assert len(results) == 1
+        assert results[0].product_release_uuid == "d4d9f54a-abcf-11ee-ac79-1a52914d44b"
+        # Verify TEI is NOT double-encoded (requests auto-encodes params)
+        request = responses.calls[0].request
+        assert "tei=" in str(request.url)
+
+    @responses.activate
+    def test_discover_empty_result(self, client, base_url):
+        responses.get(f"{base_url}/discovery", json=[])
+        results = client.discover("urn:tei:uuid:example.com:d4d9f54a")
+        assert results == []
+
+
+class TestFromWellKnown:
+    @responses.activate
+    def test_from_well_known_creates_client(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("https://api.example.com/v0.3.0-beta.2", status=200)
+        client = TeaClient.from_well_known("example.com")
+        assert client is not None
+        client.close()
+
+    @responses.activate
+    def test_from_well_known_no_compatible_version_raises(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["99.0.0"]}],
+            },
+        )
+        with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"):
+            TeaClient.from_well_known("example.com")
+
+    @responses.activate
+    def test_from_well_known_with_scheme_and_port(self):
+        responses.get(
+            "http://example.com:9080/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "http://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("http://api.example.com/v0.3.0-beta.2", status=200)
+        import warnings
+
+        from libtea.exceptions import TeaInsecureTransportWarning
+
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            client = TeaClient.from_well_known("example.com", scheme="http", port=9080)
+        assert client is not None
+        insecure_warnings = [x for x in w if issubclass(x.category, TeaInsecureTransportWarning)]
+        assert len(insecure_warnings) >= 1
+        client.close()
+
+    @responses.activate
+    def test_from_well_known_passes_token(self, base_url):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("https://api.example.com/v0.3.0-beta.2", status=200)
+        responses.get(
+            "https://api.example.com/v0.3.0-beta.2/product/f6a7b8c9-d0e1-2345-fabc-456789012345",
+            json={"uuid": "f6a7b8c9-d0e1-2345-fabc-456789012345", "name": "P", "identifiers": []},
+        )
+        client = TeaClient.from_well_known("example.com", token="secret")
+        client.get_product("f6a7b8c9-d0e1-2345-fabc-456789012345")
+        assert responses.calls[2].request.headers["authorization"] == "Bearer secret"
+        client.close()
+
+
+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.activate
+    def test_probe_404_is_ok(self):
+        """404 means the server is alive — probe should succeed."""
+        responses.head("https://api.example.com/v1", status=404)
+        probe_endpoint("https://api.example.com/v1")  # should not raise
+
+    @responses.activate
+    def test_probe_redirect_raises_connection_error(self):
+        """3xx means the server redirects — probe should fail since get_json rejects redirects."""
+        responses.head("https://api.example.com/v1", status=301, headers={"Location": "/v1/"})
+        with pytest.raises(TeaConnectionError, match="redirect"):
+            probe_endpoint("https://api.example.com/v1")
+
+    @responses.activate
+    def test_probe_302_raises_connection_error(self):
+        """302 redirect also treated as probe failure."""
+        responses.head("https://api.example.com/v1", status=302, headers={"Location": "/elsewhere"})
+        with pytest.raises(TeaConnectionError, match="redirect"):
+            probe_endpoint("https://api.example.com/v1")
+
+    @responses.activate
+    def test_probe_500_raises_server_error(self):
+        responses.head("https://api.example.com/v1", status=500)
+        with pytest.raises(TeaServerError):
+            probe_endpoint("https://api.example.com/v1")
+
+    @responses.activate
+    def test_probe_connection_error_raises(self):
+        responses.head("https://api.example.com/v1", body=requests.ConnectionError("refused"))
+        with pytest.raises(TeaConnectionError):
+            probe_endpoint("https://api.example.com/v1")
+
+    @responses.activate
+    def test_probe_timeout_raises(self):
+        responses.head("https://api.example.com/v1", body=requests.Timeout("timed out"))
+        with pytest.raises(TeaConnectionError):
+            probe_endpoint("https://api.example.com/v1")
+
+    @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"))
+        with pytest.raises(TeaConnectionError):
+            probe_endpoint("https://api.example.com/v1")
+
+
+class TestEndpointFailover:
+    """Multi-endpoint failover in from_well_known."""
+
+    WELL_KNOWN_DOC = {
+        "schemaVersion": 1,
+        "endpoints": [
+            {"url": "https://primary.example.com", "versions": ["0.3.0-beta.2"], "priority": 1.0},
+            {"url": "https://fallback.example.com", "versions": ["0.3.0-beta.2"], "priority": 0.5},
+        ],
+    }
+
+    @responses.activate
+    def test_failover_to_second_on_connection_error(self):
+        responses.get("https://example.com/.well-known/tea", json=self.WELL_KNOWN_DOC)
+        responses.head(
+            "https://primary.example.com/v0.3.0-beta.2",
+            body=requests.ConnectionError("refused"),
+        )
+        responses.head("https://fallback.example.com/v0.3.0-beta.2", status=200)
+
+        client = TeaClient.from_well_known("example.com")
+        assert client is not None
+        client.close()
+
+    @responses.activate
+    def test_failover_to_second_on_500(self):
+        responses.get("https://example.com/.well-known/tea", json=self.WELL_KNOWN_DOC)
+        responses.head("https://primary.example.com/v0.3.0-beta.2", status=500)
+        responses.head("https://fallback.example.com/v0.3.0-beta.2", status=200)
+
+        client = TeaClient.from_well_known("example.com")
+        assert client is not None
+        client.close()
+
+    @responses.activate
+    def test_all_endpoints_fail_raises_discovery_error(self):
+        responses.get("https://example.com/.well-known/tea", json=self.WELL_KNOWN_DOC)
+        responses.head(
+            "https://primary.example.com/v0.3.0-beta.2",
+            body=requests.ConnectionError("refused"),
+        )
+        responses.head(
+            "https://fallback.example.com/v0.3.0-beta.2",
+            body=requests.ConnectionError("also refused"),
+        )
+
+        with pytest.raises(TeaDiscoveryError, match="All 2 endpoint") as exc_info:
+            TeaClient.from_well_known("example.com")
+        assert exc_info.value.__cause__ is not None
+
+    @responses.activate
+    def test_all_endpoints_fail_mixed_errors_raises_discovery_error(self):
+        """Mixed TeaConnectionError + TeaServerError still raises TeaDiscoveryError."""
+        responses.get("https://example.com/.well-known/tea", json=self.WELL_KNOWN_DOC)
+        responses.head(
+            "https://primary.example.com/v0.3.0-beta.2",
+            body=requests.ConnectionError("refused"),
+        )
+        responses.head("https://fallback.example.com/v0.3.0-beta.2", status=500)
+
+        with pytest.raises(TeaDiscoveryError, match="All 2 endpoint") as exc_info:
+            TeaClient.from_well_known("example.com")
+        assert isinstance(exc_info.value.__cause__, TeaServerError)
+
+    @responses.activate
+    def test_single_endpoint_success_no_failover(self):
+        doc = {
+            "schemaVersion": 1,
+            "endpoints": [{"url": "https://only.example.com", "versions": ["0.3.0-beta.2"]}],
+        }
+        responses.get("https://example.com/.well-known/tea", json=doc)
+        responses.head("https://only.example.com/v0.3.0-beta.2", status=200)
+
+        client = TeaClient.from_well_known("example.com")
+        assert client is not None
+        client.close()
+
+    @responses.activate
+    def test_failover_uses_correct_base_url(self):
+        """After failover, the client should use the fallback endpoint's URL."""
+        responses.get("https://example.com/.well-known/tea", json=self.WELL_KNOWN_DOC)
+        responses.head("https://primary.example.com/v0.3.0-beta.2", status=503)
+        responses.head("https://fallback.example.com/v0.3.0-beta.2", status=200)
+        responses.get(
+            "https://fallback.example.com/v0.3.0-beta.2/product/f6a7b8c9-d0e1-2345-fabc-456789012345",
+            json={"uuid": "f6a7b8c9-d0e1-2345-fabc-456789012345", "name": "P", "identifiers": []},
+        )
+
+        client = TeaClient.from_well_known("example.com")
+        product = client.get_product("f6a7b8c9-d0e1-2345-fabc-456789012345")
+        assert product.name == "P"
+        client.close()
+
+
+class TestPagination:
+    @responses.activate
+    def test_get_product_releases_pagination_params(self, client, base_url):
+        responses.get(
+            f"{base_url}/product/a1b2c3d4-e5f6-7890-abcd-ef1234567890/releases",
+            json={
+                "timestamp": "2024-03-20T15:30:00Z",
+                "pageStartIndex": 50,
+                "pageSize": 25,
+                "totalResults": 200,
+                "results": [],
+            },
+        )
+        resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_offset=50, page_size=25)
+        request = responses.calls[0].request
+        assert "pageOffset=50" in str(request.url)
+        assert "pageSize=25" in str(request.url)
+        assert resp.page_start_index == 50
+
+
+class TestProductReleaseCollections:
+    @responses.activate
+    def test_get_product_release_collections(self, client, base_url):
+        responses.get(
+            f"{base_url}/productRelease/b2c3d4e5-f6a7-8901-bcde-f12345678901/collections",
+            json=[
+                {"uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "version": 1, "artifacts": []},
+                {"uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "version": 2, "artifacts": []},
+            ],
+        )
+        collections = client.get_product_release_collections("b2c3d4e5-f6a7-8901-bcde-f12345678901")
+        assert len(collections) == 2
+        assert collections[0].version == 1
+
+    @responses.activate
+    def test_get_product_release_collection_by_version(self, client, base_url):
+        responses.get(
+            f"{base_url}/productRelease/b2c3d4e5-f6a7-8901-bcde-f12345678901/collection/5",
+            json={"uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "version": 5, "artifacts": []},
+        )
+        collection = client.get_product_release_collection("b2c3d4e5-f6a7-8901-bcde-f12345678901", 5)
+        assert collection.version == 5
+
+
+class TestValidationErrors:
+    @responses.activate
+    def test_validate_raises_tea_validation_error(self, client, base_url):
+        # Missing required fields triggers Pydantic ValidationError → TeaValidationError
+        responses.get(f"{base_url}/product/f6a7b8c9-d0e1-2345-fabc-456789012345", json={"bad": "data"})
+        with pytest.raises(TeaValidationError, match="Invalid Product response"):
+            client.get_product("f6a7b8c9-d0e1-2345-fabc-456789012345")
+
+    @responses.activate
+    def test_validate_list_raises_tea_validation_error(self, client, base_url):
+        # List with invalid items triggers Pydantic ValidationError → TeaValidationError
+        responses.get(
+            f"{base_url}/component/c3d4e5f6-a7b8-9012-cdef-123456789012/releases",
+            json=[{"bad": "data"}],
+        )
+        with pytest.raises(TeaValidationError, match="Invalid Release response"):
+            client.get_component_releases("c3d4e5f6-a7b8-9012-cdef-123456789012")
+
+    @responses.activate
+    def test_validate_list_rejects_non_list_response(self, client, base_url):
+        responses.get(f"{base_url}/component/c3d4e5f6-a7b8-9012-cdef-123456789012/releases", json={"not": "a list"})
+        with pytest.raises(TeaValidationError, match="Expected list"):
+            client.get_component_releases("c3d4e5f6-a7b8-9012-cdef-123456789012")
+
+
+class TestContextManager:
+    @responses.activate
+    def test_client_as_context_manager(self, base_url):
+        responses.get(
+            f"{base_url}/component/a7b8c9d0-e1f2-3456-abcd-567890123456",
+            json={"uuid": "a7b8c9d0-e1f2-3456-abcd-567890123456", "name": "C1", "identifiers": []},
+        )
+        with TeaClient(base_url=base_url) as client:
+            component = client.get_component("a7b8c9d0-e1f2-3456-abcd-567890123456")
+            assert component.name == "C1"
+
+
+_CLE_RESPONSE = {
+    "events": [
+        {
+            "id": 1,
+            "type": "released",
+            "effective": "2024-01-01T00:00:00Z",
+            "published": "2024-01-01T00:00:00Z",
+            "version": "1.0.0",
+        }
+    ]
+}
+
+
+class TestCLE:
+    @responses.activate
+    def test_get_product_cle(self, client, base_url):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{base_url}/product/{uuid}/cle", json=_CLE_RESPONSE)
+        cle = client.get_product_cle(uuid)
+        assert isinstance(cle, CLE)
+        assert len(cle.events) == 1
+        assert cle.events[0].type == "released"
+        assert cle.events[0].version == "1.0.0"
+        assert cle.events[0].id == 1
+
+    @responses.activate
+    def test_get_product_release_cle(self, client, base_url):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{base_url}/productRelease/{uuid}/cle", json=_CLE_RESPONSE)
+        cle = client.get_product_release_cle(uuid)
+        assert isinstance(cle, CLE)
+        assert f"/productRelease/{uuid}/cle" in responses.calls[0].request.url
+
+    @responses.activate
+    def test_get_component_cle(self, client, base_url):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{base_url}/component/{uuid}/cle", json=_CLE_RESPONSE)
+        cle = client.get_component_cle(uuid)
+        assert isinstance(cle, CLE)
+        assert f"/component/{uuid}/cle" in responses.calls[0].request.url
+
+    @responses.activate
+    def test_get_component_release_cle(self, client, base_url):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{base_url}/componentRelease/{uuid}/cle", json=_CLE_RESPONSE)
+        cle = client.get_component_release_cle(uuid)
+        assert isinstance(cle, CLE)
+        assert f"/componentRelease/{uuid}/cle" in responses.calls[0].request.url
+
+    def test_get_product_cle_rejects_unsafe_uuid(self, client):
+        with pytest.raises(TeaValidationError, match="Invalid uuid"):
+            client.get_product_cle("../../etc/passwd")
+
+    @responses.activate
+    def test_get_product_cle_malformed_response_raises(self, client, base_url):
+        uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        responses.get(f"{base_url}/product/{uuid}/cle", json={"bad": "data"})
+        with pytest.raises(TeaValidationError, match="Invalid CLE response"):
+            client.get_product_cle(uuid)
+
+
+class TestProbeEndpointMtls:
+    """probe_endpoint passes mTLS config to the standalone HEAD request."""
+
+    @responses.activate
+    def test_probe_with_mtls_config(self):
+        responses.head("https://api.example.com/v1", status=200)
+        mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem"))
+        probe_endpoint("https://api.example.com/v1", mtls=mtls)  # should not raise
+
+    @responses.activate
+    def test_probe_with_mtls_ca_bundle(self):
+        responses.head("https://api.example.com/v1", status=200)
+        mtls = MtlsConfig(
+            client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem"), ca_bundle=Path("/tmp/ca.pem")
+        )
+        probe_endpoint("https://api.example.com/v1", mtls=mtls)  # should not raise
+
+    @responses.activate
+    def test_from_well_known_passes_mtls_to_probe(self):
+        """from_well_known must propagate mTLS config to probe_endpoint."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}],
+            },
+        )
+        responses.head("https://api.example.com/v0.3.0-beta.2", status=200)
+        mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem"))
+        client = TeaClient.from_well_known("example.com", mtls=mtls)
+        assert client is not None
+        client.close()
+
+
+class TestWeakChecksumWarning:
+    """P2-5: Weak hash algorithms emit a warning."""
+
+    @responses.activate
+    def test_md5_checksum_warns(self, client, tmp_path):
+        import hashlib
+        import warnings
+
+        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)]
+        dest = tmp_path / "sbom.json"
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            client.download_artifact("https://artifacts.example.com/sbom.json", dest, verify_checksums=checksums)
+        weak_warnings = [x for x in w if "weak hash" in str(x.message).lower()]
+        assert len(weak_warnings) == 1
+
+    @responses.activate
+    def test_sha256_no_warning(self, client, tmp_path):
+        import hashlib
+        import warnings
+
+        content = b"test content"
+        responses.get("https://artifacts.example.com/sbom.json", body=content)
+        sha256 = hashlib.sha256(content).hexdigest()
+        checksums = [Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value=sha256)]
+        dest = tmp_path / "sbom.json"
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            client.download_artifact("https://artifacts.example.com/sbom.json", dest, verify_checksums=checksums)
+        weak_warnings = [x for x in w if "weak hash" in str(x.message).lower()]
+        assert len(weak_warnings) == 0
diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py
new file mode 100644
index 0000000..fc44424
--- /dev/null
+++ b/tests/client/test_discovery.py
@@ -0,0 +1,572 @@
+import pytest
+import requests
+import responses
+from pydantic import ValidationError
+
+from libtea.discovery import _is_valid_domain, fetch_well_known, parse_tei, select_endpoint, select_endpoints
+from libtea.exceptions import TeaDiscoveryError, TeaInsecureTransportWarning
+from libtea.models import DiscoveryInfo, TeaEndpoint, TeaWellKnown, TeiType
+
+
+class TestParseTei:
+    def test_uuid_tei(self):
+        tei = "urn:tei:uuid:products.example.com:d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+        tei_type, domain, identifier = parse_tei(tei)
+        assert tei_type == "uuid"
+        assert domain == "products.example.com"
+        assert identifier == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1"
+
+    def test_purl_tei(self):
+        tei = "urn:tei:purl:cyclonedx.org:pkg:pypi/cyclonedx-python-lib@8.4.0"
+        tei_type, domain, identifier = parse_tei(tei)
+        assert tei_type == "purl"
+        assert domain == "cyclonedx.org"
+        assert identifier == "pkg:pypi/cyclonedx-python-lib@8.4.0"
+
+    def test_hash_tei(self):
+        tei = "urn:tei:hash:cyclonedx.org:SHA256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9"
+        tei_type, domain, identifier = parse_tei(tei)
+        assert tei_type == "hash"
+        assert domain == "cyclonedx.org"
+        assert identifier == "SHA256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9"
+
+    def test_invalid_tei_no_urn_prefix(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid TEI"):
+            parse_tei("not-a-tei")
+
+    def test_invalid_tei_wrong_prefix(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid TEI"):
+            parse_tei("urn:other:uuid:example.com:123")
+
+    def test_invalid_tei_too_few_parts(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid TEI"):
+            parse_tei("urn:tei:uuid")
+
+    def test_invalid_tei_empty_string(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid TEI"):
+            parse_tei("")
+
+    def test_invalid_tei_unknown_type(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid TEI type"):
+            parse_tei("urn:tei:unknown:example.com:some-id")
+
+    @pytest.mark.parametrize("tei_type", [e.value for e in TeiType])
+    def test_all_valid_tei_types(self, tei_type):
+        result_type, domain, identifier = parse_tei(f"urn:tei:{tei_type}:example.com:some-id")
+        assert result_type == tei_type
+        assert domain == "example.com"
+        assert identifier == "some-id"
+
+    def test_invalid_tei_empty_domain(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            parse_tei("urn:tei:uuid::some-id")
+
+    def test_invalid_tei_bad_domain_format(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            parse_tei("urn:tei:uuid:-invalid.com:some-id")
+
+    def test_invalid_tei_domain_with_underscore(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            parse_tei("urn:tei:uuid:bad_domain.com:some-id")
+
+    def test_valid_tei_subdomain(self):
+        _, domain, _ = parse_tei("urn:tei:uuid:products.tea.example.com:some-id")
+        assert domain == "products.tea.example.com"
+
+    def test_valid_tei_single_label_domain(self):
+        _, domain, _ = parse_tei("urn:tei:uuid:localhost:some-id")
+        assert domain == "localhost"
+
+    def test_tei_with_slash_in_purl_identifier(self):
+        tei = "urn:tei:purl:cyclonedx.org:pkg:maven/org.apache/log4j@2.24.3"
+        tei_type, domain, identifier = parse_tei(tei)
+        assert tei_type == "purl"
+        assert domain == "cyclonedx.org"
+        assert identifier == "pkg:maven/org.apache/log4j@2.24.3"
+
+
+class TestFetchWellKnown:
+    @responses.activate
+    def test_fetch_well_known_success(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        wk = fetch_well_known("example.com")
+        assert wk.schema_version == 1
+        assert len(wk.endpoints) == 1
+
+    @responses.activate
+    def test_fetch_well_known_sends_user_agent(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        fetch_well_known("example.com")
+        ua = responses.calls[0].request.headers["user-agent"]
+        assert ua.startswith("py-libtea/")
+        assert "hello@sbomify.com" in ua
+
+    @responses.activate
+    def test_fetch_well_known_404_raises_discovery_error(self):
+        responses.get("https://example.com/.well-known/tea", status=404)
+        with pytest.raises(TeaDiscoveryError, match="HTTP 404"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_fetch_well_known_connection_error(self):
+        responses.get("https://example.com/.well-known/tea", body=requests.ConnectionError("refused"))
+        with pytest.raises(TeaDiscoveryError, match="Failed to connect"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_fetch_well_known_timeout_error(self):
+        responses.get("https://example.com/.well-known/tea", body=requests.Timeout("timed out"))
+        with pytest.raises(TeaDiscoveryError, match="Failed to connect"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_fetch_well_known_500_raises_discovery_error(self):
+        responses.get("https://example.com/.well-known/tea", status=500)
+        with pytest.raises(TeaDiscoveryError):
+            fetch_well_known("example.com")
+
+    def test_fetch_well_known_empty_domain_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            fetch_well_known("")
+
+    def test_fetch_well_known_invalid_domain_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            fetch_well_known("-bad.com")
+
+    def test_fetch_well_known_underscore_domain_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid domain"):
+            fetch_well_known("bad_domain.com")
+
+    @responses.activate
+    def test_fetch_well_known_request_exception(self):
+        responses.get("https://example.com/.well-known/tea", body=requests.exceptions.TooManyRedirects("too many"))
+        with pytest.raises(TeaDiscoveryError, match="HTTP error"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_fetch_well_known_http_scheme(self):
+        responses.get(
+            "http://example.com/.well-known/tea",
+            json={"schemaVersion": 1, "endpoints": [{"url": "http://api.example.com", "versions": ["1.0.0"]}]},
+        )
+        wk = fetch_well_known("example.com", scheme="http")
+        assert len(wk.endpoints) == 1
+
+    @responses.activate
+    def test_fetch_well_known_custom_port(self):
+        responses.get(
+            "https://example.com:8443/.well-known/tea",
+            json={"schemaVersion": 1, "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}]},
+        )
+        wk = fetch_well_known("example.com", port=8443)
+        assert len(wk.endpoints) == 1
+
+    @responses.activate
+    def test_fetch_well_known_default_port_omitted(self):
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={"schemaVersion": 1, "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}]},
+        )
+        wk = fetch_well_known("example.com", port=443)
+        assert len(wk.endpoints) == 1
+
+    def test_fetch_well_known_invalid_scheme_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid scheme"):
+            fetch_well_known("example.com", scheme="ftp")
+
+    def test_fetch_well_known_invalid_port_zero_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid port"):
+            fetch_well_known("example.com", port=0)
+
+    def test_fetch_well_known_invalid_port_negative_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid port"):
+            fetch_well_known("example.com", port=-1)
+
+    def test_fetch_well_known_invalid_port_too_large_raises(self):
+        with pytest.raises(TeaDiscoveryError, match="Invalid port"):
+            fetch_well_known("example.com", port=70000)
+
+    @responses.activate
+    def test_fetch_well_known_http_default_port_omitted(self):
+        responses.get(
+            "http://example.com/.well-known/tea",
+            json={"schemaVersion": 1, "endpoints": [{"url": "http://api.example.com", "versions": ["1.0.0"]}]},
+        )
+        wk = fetch_well_known("example.com", scheme="http", port=80)
+        assert len(wk.endpoints) == 1
+
+    def test_fetch_well_known_http_emits_insecure_warning(self):
+        import warnings
+
+        from libtea.exceptions import TeaInsecureTransportWarning
+
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            try:
+                fetch_well_known("example.com", scheme="http")
+            except TeaDiscoveryError:
+                pass  # Connection will fail; we only care about the warning
+            insecure_warnings = [x for x in w if issubclass(x.category, TeaInsecureTransportWarning)]
+            assert len(insecure_warnings) == 1
+
+    @responses.activate
+    def test_fetch_well_known_http_with_custom_port(self):
+        responses.get(
+            "http://example.com:9080/.well-known/tea",
+            json={"schemaVersion": 1, "endpoints": [{"url": "http://api.example.com", "versions": ["1.0.0"]}]},
+        )
+        wk = fetch_well_known("example.com", scheme="http", port=9080)
+        assert len(wk.endpoints) == 1
+
+    @responses.activate
+    def test_fetch_well_known_non_json_raises_discovery_error(self):
+        responses.get("https://example.com/.well-known/tea", body="not json")
+        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"})
+        with pytest.raises(TeaDiscoveryError, match="Invalid .well-known/tea"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_fetch_well_known_with_mtls(self):
+        """P2-3: mTLS config should be forwarded to the discovery request."""
+        from pathlib import Path
+
+        from libtea._http import MtlsConfig
+
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem"))
+        wk = fetch_well_known("example.com", mtls=mtls)
+        assert len(wk.endpoints) == 1
+
+    @responses.activate
+    def test_fetch_well_known_with_mtls_ca_bundle(self):
+        """mTLS with ca_bundle should set verify= on the request."""
+        from pathlib import Path
+
+        from libtea._http import MtlsConfig
+
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        mtls = MtlsConfig(
+            client_cert=Path("/tmp/cert.pem"),
+            client_key=Path("/tmp/key.pem"),
+            ca_bundle=Path("/tmp/ca-bundle.pem"),
+        )
+        wk = fetch_well_known("example.com", mtls=mtls)
+        assert len(wk.endpoints) == 1
+
+
+class TestFetchWellKnownSsrfProtection:
+    """P2-2: Post-redirect SSRF validation in fetch_well_known."""
+
+    @responses.activate
+    def test_rejects_redirect_to_unsupported_scheme(self):
+        """If the server redirects to a non-http(s) scheme, raise."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            status=301,
+            headers={"Location": "ftp://evil.example.com/.well-known/tea"},
+        )
+        with pytest.raises(TeaDiscoveryError, match="unsupported scheme.*ftp"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_allows_https_redirect(self):
+        """A normal HTTPS redirect should succeed."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        wk = fetch_well_known("example.com")
+        assert wk.schema_version == 1
+
+    @responses.activate
+    def test_warns_on_https_to_http_downgrade(self):
+        """HTTPS→HTTP redirect should emit a warning."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            status=301,
+            headers={"Location": "http://other.example.com/.well-known/tea"},
+        )
+        responses.get(
+            "http://other.example.com/.well-known/tea",
+            json={
+                "schemaVersion": 1,
+                "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}],
+            },
+        )
+        with pytest.warns(TeaInsecureTransportWarning, match="downgraded from HTTPS to HTTP"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_rejects_redirect_to_internal_ip(self):
+        """Redirect to an internal IP (e.g. cloud metadata) should raise."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            status=302,
+            headers={"Location": "http://169.254.169.254/latest/meta-data/"},
+        )
+        with pytest.raises(TeaDiscoveryError, match="redirected to blocked target"):
+            fetch_well_known("example.com")
+
+    @responses.activate
+    def test_rejects_redirect_to_localhost(self):
+        """Redirect to localhost should raise."""
+        responses.get(
+            "https://example.com/.well-known/tea",
+            status=302,
+            headers={"Location": "http://localhost/admin"},
+        )
+        with pytest.raises(TeaDiscoveryError, match="redirected to blocked target"):
+            fetch_well_known("example.com")
+
+
+class TestSelectEndpoint:
+    def _make_well_known(self, endpoints: list[dict]) -> TeaWellKnown:
+        return TeaWellKnown(
+            schema_version=1,
+            endpoints=[TeaEndpoint(**ep) for ep in endpoints],
+        )
+
+    def test_selects_matching_version(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["1.0.0"]},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        assert ep.url == "https://api.example.com"
+
+    def test_selects_highest_priority(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://low.example.com", "versions": ["1.0.0"], "priority": 0.5},
+                {"url": "https://high.example.com", "versions": ["1.0.0"], "priority": 1.0},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        assert ep.url == "https://high.example.com"
+
+    def test_no_matching_version_raises(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["2.0.0"]},
+            ]
+        )
+        with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"):
+            select_endpoint(wk, "1.0.0")
+
+    def test_prefers_highest_matching_version(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://old.example.com", "versions": ["0.1.0"]},
+                {"url": "https://new.example.com", "versions": ["0.1.0", "1.0.0"]},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        assert ep.url == "https://new.example.com"
+
+    def test_empty_endpoints_rejected_by_model(self):
+        """TeaWellKnown enforces min_length=1 on endpoints per spec."""
+        with pytest.raises(ValidationError):
+            TeaWellKnown(schema_version=1, endpoints=[])
+
+    def test_none_priority_defaults_to_1(self):
+        """Endpoint without priority defaults to 1.0 (highest), matching spec default."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://none-priority.example.com", "versions": ["1.0.0"]},
+                {"url": "https://low-priority.example.com", "versions": ["1.0.0"], "priority": 0.5},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        assert ep.url == "https://none-priority.example.com"
+
+    def test_invalid_semver_two_part_version_skipped(self):
+        """Two-part version '1.0' is not valid SemVer and is silently skipped."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["1.0"]},
+            ]
+        )
+        with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"):
+            select_endpoint(wk, "1.0.0")
+
+    def test_semver_matches_with_prerelease(self):
+        """Pre-release versions match exactly."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]},
+            ]
+        )
+        ep = select_endpoint(wk, "0.3.0-beta.2")
+        assert ep.url == "https://api.example.com"
+
+    def test_semver_prerelease_does_not_match_release(self):
+        """Pre-release '1.0.0-beta.1' should not match '1.0.0'."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["1.0.0-beta.1"]},
+            ]
+        )
+        with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"):
+            select_endpoint(wk, "1.0.0")
+
+    def test_invalid_semver_in_endpoint_skipped(self):
+        """Invalid version strings in endpoint are silently skipped."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["not-semver", "1.0.0"]},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        assert ep.url == "https://api.example.com"
+
+    def test_priority_out_of_range_rejected(self):
+        """Priority > 1.0 should be rejected by model validation."""
+        with pytest.raises(ValidationError):
+            TeaEndpoint(url="https://api.example.com", versions=["1.0.0"], priority=2.0)
+
+    def test_empty_versions_rejected(self):
+        """Endpoint with empty versions list should be rejected by model validation."""
+        with pytest.raises(ValidationError):
+            TeaEndpoint(url="https://api.example.com", versions=[])
+
+
+class TestDiscoveryInfo:
+    def test_rejects_empty_servers(self):
+        """Spec requires minItems: 1 for servers array."""
+        with pytest.raises(ValidationError):
+            DiscoveryInfo(product_release_uuid="d4d9f54a-abcf-11ee-ac79-1a52914d44b1", servers=[])
+
+
+class TestIsValidDomain:
+    def test_rejects_empty_string(self):
+        assert not _is_valid_domain("")
+
+    def test_rejects_label_over_63_chars(self):
+        assert not _is_valid_domain("a" * 64 + ".com")
+
+    def test_accepts_label_at_63_chars(self):
+        assert _is_valid_domain("a" * 63 + ".com")
+
+    def test_rejects_trailing_dot(self):
+        assert not _is_valid_domain("example.com.")
+
+    def test_rejects_double_dot(self):
+        assert not _is_valid_domain("example..com")
+
+    def test_rejects_leading_hyphen_label(self):
+        assert not _is_valid_domain("-example.com")
+
+    def test_rejects_trailing_hyphen_label(self):
+        assert not _is_valid_domain("example-.com")
+
+    def test_accepts_hyphen_in_middle(self):
+        assert _is_valid_domain("my-example.com")
+
+    def test_rejects_underscore(self):
+        assert not _is_valid_domain("my_example.com")
+
+    def test_accepts_single_label(self):
+        assert _is_valid_domain("localhost")
+
+    def test_rejects_domain_over_253_chars(self):
+        """RFC 1035 limits total domain name to 253 characters."""
+        long_domain = ".".join(["a" * 63] * 4)  # 63*4 + 3 dots = 255 chars
+        assert len(long_domain) == 255
+        assert not _is_valid_domain(long_domain)
+
+    def test_accepts_domain_at_253_chars(self):
+        # 61-char labels * 4 + 3 dots = 247, well under 253
+        domain = ".".join(["a" * 61] * 4)
+        assert len(domain) <= 253
+        assert _is_valid_domain(domain)
+
+
+class TestSelectEndpoints:
+    def _make_well_known(self, endpoints: list[dict]) -> TeaWellKnown:
+        return TeaWellKnown(
+            schema_version=1,
+            endpoints=[TeaEndpoint(**ep) for ep in endpoints],
+        )
+
+    def test_returns_all_matching_endpoints(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://a.example.com", "versions": ["1.0.0"], "priority": 0.5},
+                {"url": "https://b.example.com", "versions": ["1.0.0"], "priority": 1.0},
+                {"url": "https://c.example.com", "versions": ["2.0.0"]},
+            ]
+        )
+        eps = select_endpoints(wk, "1.0.0")
+        assert len(eps) == 2
+        assert eps[0].url == "https://b.example.com"
+        assert eps[1].url == "https://a.example.com"
+
+    def test_single_candidate(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://only.example.com", "versions": ["1.0.0"]},
+            ]
+        )
+        eps = select_endpoints(wk, "1.0.0")
+        assert len(eps) == 1
+        assert eps[0].url == "https://only.example.com"
+
+    def test_no_match_raises(self):
+        wk = self._make_well_known(
+            [
+                {"url": "https://api.example.com", "versions": ["2.0.0"]},
+            ]
+        )
+        with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"):
+            select_endpoints(wk, "1.0.0")
+
+    def test_select_endpoint_returns_first(self):
+        """select_endpoint (singular) returns the best candidate from select_endpoints."""
+        wk = self._make_well_known(
+            [
+                {"url": "https://low.example.com", "versions": ["1.0.0"], "priority": 0.3},
+                {"url": "https://high.example.com", "versions": ["1.0.0"], "priority": 0.9},
+            ]
+        )
+        ep = select_endpoint(wk, "1.0.0")
+        eps = select_endpoints(wk, "1.0.0")
+        assert ep.url == eps[0].url
+
+    def test_invalid_version_string_raises_discovery_error(self):
+        """select_endpoints wraps ValueError from invalid version strings in TeaDiscoveryError."""
+        wk = self._make_well_known([{"url": "https://api.example.com", "versions": ["1.0.0"]}])
+        with pytest.raises(TeaDiscoveryError, match="Invalid version string"):
+            select_endpoints(wk, "not-a-version")
diff --git a/tests/test_download.py b/tests/client/test_download.py
similarity index 87%
rename from tests/test_download.py
rename to tests/client/test_download.py
index dcc20a1..9363419 100644
--- a/tests/test_download.py
+++ b/tests/client/test_download.py
@@ -104,3 +104,17 @@ def test_download_multi_chunk_artifact(self, client, tmp_path):
         result = client.download_artifact(ARTIFACT_URL, dest, verify_checksums=checksums)
         assert result == dest
         assert dest.read_bytes() == content
+
+    @responses.activate
+    def test_multi_checksum_partial_failure(self, client, tmp_path):
+        """First checksum passes but second fails — file should be deleted."""
+        responses.get(ARTIFACT_URL, body=ARTIFACT_CONTENT)
+        sha1 = hashlib.sha1(ARTIFACT_CONTENT).hexdigest()
+        checksums = [
+            Checksum(algorithm_type=ChecksumAlgorithm.SHA_1, algorithm_value=sha1),
+            Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="0000deadbeef"),
+        ]
+        dest = tmp_path / "partial.json"
+        with pytest.raises(TeaChecksumError, match="SHA-256"):
+            client.download_artifact(ARTIFACT_URL, dest, verify_checksums=checksums)
+        assert not dest.exists()
diff --git a/tests/test_http.py b/tests/client/test_http.py
similarity index 54%
rename from tests/test_http.py
rename to tests/client/test_http.py
index 5897c34..e1fdbd5 100644
--- a/tests/test_http.py
+++ b/tests/client/test_http.py
@@ -1,15 +1,20 @@
 import hashlib
 import warnings
+from pathlib import Path
 from unittest.mock import patch
 
 import pytest
 import requests
 import responses
 
-from libtea._http import TeaHttpClient, _build_hashers, _get_package_version, _validate_download_url
+from libtea._http import (
+    _MAX_DOWNLOAD_REDIRECTS,
+    MtlsConfig,
+    TeaHttpClient,
+    _get_package_version,
+)
 from libtea.exceptions import (
     TeaAuthenticationError,
-    TeaChecksumError,
     TeaConnectionError,
     TeaInsecureTransportWarning,
     TeaNotFoundError,
@@ -264,90 +269,6 @@ def test_fallback_to_unknown(self):
             assert result == "unknown"
 
 
-class TestBuildHashers:
-    def test_blake3_raises(self):
-        with pytest.raises(TeaChecksumError, match="BLAKE3"):
-            _build_hashers(["BLAKE3"])
-
-    def test_unknown_algorithm_raises(self):
-        with pytest.raises(TeaChecksumError, match="Unsupported checksum algorithm"):
-            _build_hashers(["UNKNOWN-ALG"])
-
-    @pytest.mark.parametrize(
-        "algorithm",
-        ["MD5", "SHA-1", "SHA-256", "SHA-384", "SHA-512", "SHA3-256", "SHA3-384", "SHA3-512"],
-    )
-    def test_standard_algorithms(self, algorithm):
-        hashers = _build_hashers([algorithm])
-        assert algorithm in hashers
-        # Verify the hasher produces a hex digest
-        hashers[algorithm].update(b"test")
-        assert len(hashers[algorithm].hexdigest()) > 0
-
-    @pytest.mark.parametrize("algorithm,digest_size", [("BLAKE2b-256", 32), ("BLAKE2b-384", 48), ("BLAKE2b-512", 64)])
-    def test_blake2b_variants(self, algorithm, digest_size):
-        hashers = _build_hashers([algorithm])
-        assert algorithm in hashers
-        hashers[algorithm].update(b"test")
-        # BLAKE2b hex digest length = digest_size * 2
-        assert len(hashers[algorithm].hexdigest()) == digest_size * 2
-
-    @responses.activate
-    def test_all_algorithms_produce_correct_digests(self, tmp_path):
-        """End-to-end: download with each algorithm and verify the digest is correct."""
-        content = b"algorithm test content"
-        url = "https://artifacts.example.com/test.bin"
-        responses.get(url, body=content)
-
-        client = TeaHttpClient(base_url="https://api.example.com")
-        all_algs = ["MD5", "SHA-1", "SHA-256", "SHA-384", "SHA-512", "SHA3-256", "SHA3-384", "SHA3-512"]
-
-        dest = tmp_path / "test.bin"
-        digests = client.download_with_hashes(url=url, dest=dest, algorithms=all_algs)
-        client.close()
-
-        assert digests["MD5"] == hashlib.md5(content).hexdigest()
-        assert digests["SHA-1"] == hashlib.sha1(content).hexdigest()
-        assert digests["SHA-256"] == hashlib.sha256(content).hexdigest()
-        assert digests["SHA-384"] == hashlib.sha384(content).hexdigest()
-        assert digests["SHA-512"] == hashlib.sha512(content).hexdigest()
-        assert digests["SHA3-256"] == hashlib.new("sha3_256", content).hexdigest()
-        assert digests["SHA3-384"] == hashlib.new("sha3_384", content).hexdigest()
-        assert digests["SHA3-512"] == hashlib.new("sha3_512", content).hexdigest()
-
-
-class TestValidateDownloadUrl:
-    def test_rejects_file_scheme(self):
-        with pytest.raises(TeaValidationError, match="http or https scheme"):
-            _validate_download_url("file:///etc/passwd")
-
-    def test_rejects_ftp_scheme(self):
-        with pytest.raises(TeaValidationError, match="http or https scheme"):
-            _validate_download_url("ftp://evil.com/file")
-
-    def test_rejects_data_scheme(self):
-        with pytest.raises(TeaValidationError, match="http or https scheme"):
-            _validate_download_url("data:text/html,

hi

") - - def test_rejects_gopher_scheme(self): - with pytest.raises(TeaValidationError, match="http or https scheme"): - _validate_download_url("gopher://evil.com") - - def test_rejects_unknown_scheme(self): - with pytest.raises(TeaValidationError, match="http or https scheme"): - _validate_download_url("javascript:alert(1)") - - def test_rejects_missing_hostname(self): - with pytest.raises(TeaValidationError, match="must include a hostname"): - _validate_download_url("http:///path/only") - - def test_accepts_http(self): - _validate_download_url("http://example.com/file.xml") - - def test_accepts_https(self): - _validate_download_url("https://cdn.example.com/sbom.json") - - class TestRequestExceptionCatchAll: @responses.activate def test_request_exception_in_get_json(self, http_client, base_url): @@ -365,6 +286,18 @@ def test_download_timeout_cleans_up(self, http_client, tmp_path): http_client.download_with_hashes(url="https://artifacts.example.com/sbom.xml", dest=dest) assert not dest.exists() + @responses.activate + def test_download_request_exception_cleans_up(self, http_client, tmp_path): + """RequestException during download cleans up partial file.""" + responses.get( + "https://artifacts.example.com/sbom.xml", + body=requests.exceptions.ChunkedEncodingError("broken"), + ) + dest = tmp_path / "sbom.xml" + with pytest.raises(TeaConnectionError, match="Download failed"): + http_client.download_with_hashes(url="https://artifacts.example.com/sbom.xml", dest=dest) + assert not dest.exists() + class TestEmptyBodyErrors: @responses.activate @@ -381,3 +314,243 @@ def test_404_with_json_array_body(self, http_client, base_url): with pytest.raises(TeaNotFoundError) as exc_info: http_client.get_json("/product/abc") assert exc_info.value.error_type is None + + +BASE_URL = "https://api.example.com/tea/v1" + + +class TestBasicAuth: + @responses.activate + def test_basic_auth_sends_header(self): + responses.get(f"{BASE_URL}/test", json={"ok": True}) + with TeaHttpClient(base_url=BASE_URL, basic_auth=("user", "pass")) as client: + client.get_json("/test") + assert responses.calls[0].request.headers["Authorization"].startswith("Basic ") + + def test_token_and_basic_auth_raises(self): + with pytest.raises(ValueError, match="Cannot use both"): + TeaHttpClient(base_url=BASE_URL, token="tok", basic_auth=("user", "pass")) + + def test_basic_auth_over_http_raises(self): + with pytest.raises(ValueError, match="Cannot use basic auth with plaintext HTTP"): + TeaHttpClient(base_url="http://example.com/api", basic_auth=("user", "pass")) + + def test_close_clears_auth(self): + client = TeaHttpClient(base_url=BASE_URL, basic_auth=("user", "pass")) + assert client._session.auth is not None + client.close() + assert client._session.auth is None + + def test_close_clears_mtls_cert(self): + """P2-4: close() should clear mTLS cert references.""" + mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem")) + client = TeaHttpClient(base_url=BASE_URL, mtls=mtls) + assert client._session.cert is not None + client.close() + assert client._session.cert is None + + @responses.activate + def test_basic_auth_not_sent_to_download(self, tmp_path): + """Basic auth must NOT leak to artifact download URLs.""" + artifact_url = "https://cdn.example.com/sbom.xml" + responses.get(artifact_url, body=b"content") + with TeaHttpClient(base_url=BASE_URL, basic_auth=("user", "pass")) as client: + client.download_with_hashes(url=artifact_url, dest=tmp_path / "test_dl.xml") + assert "Authorization" not in responses.calls[0].request.headers + + +class TestMtlsConfig: + def test_mtls_sets_cert_on_session(self): + mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem")) + client = TeaHttpClient(base_url=BASE_URL, mtls=mtls) + assert client._session.cert == ("/tmp/cert.pem", "/tmp/key.pem") + client.close() + + def test_mtls_with_ca_bundle(self): + mtls = MtlsConfig( + client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem"), ca_bundle=Path("/tmp/ca.pem") + ) + client = TeaHttpClient(base_url=BASE_URL, mtls=mtls) + assert client._session.verify == "/tmp/ca.pem" + client.close() + + def test_mtls_without_ca_uses_default(self): + mtls = MtlsConfig(client_cert=Path("/tmp/cert.pem"), client_key=Path("/tmp/key.pem")) + client = TeaHttpClient(base_url=BASE_URL, mtls=mtls) + assert client._session.verify is True + client.close() + + +class TestRetryConfig: + def test_default_retry_is_configured(self): + client = TeaHttpClient(base_url=BASE_URL) + adapter = client._session.get_adapter(BASE_URL) + assert adapter.max_retries.total == 3 + assert 500 in adapter.max_retries.status_forcelist + client.close() + + def test_custom_retry_config(self): + client = TeaHttpClient(base_url=BASE_URL, max_retries=5, backoff_factor=1.0) + adapter = client._session.get_adapter(BASE_URL) + assert adapter.max_retries.total == 5 + assert adapter.max_retries.backoff_factor == 1.0 + client.close() + + def test_zero_retries_disables(self): + client = TeaHttpClient(base_url=BASE_URL, max_retries=0) + adapter = client._session.get_adapter(BASE_URL) + assert adapter.max_retries.total == 0 + client.close() + + def test_negative_retries_raises(self): + with pytest.raises(ValueError, match="max_retries must be >= 0"): + TeaHttpClient(base_url=BASE_URL, max_retries=-1) + + def test_retry_after_header_ignored(self): + """P2-2: Server-controlled Retry-After must not be honored to prevent stalling.""" + client = TeaHttpClient(base_url=BASE_URL) + adapter = client._session.get_adapter(BASE_URL) + assert adapter.max_retries.respect_retry_after_header is False + client.close() + + +class TestDownloadRedirectHandling: + """Download follows redirects with SSRF validation at each hop.""" + + @responses.activate + def test_follows_redirect_to_safe_url(self, http_client, tmp_path): + responses.get( + "https://artifacts.example.com/sbom.xml", + status=302, + headers={"Location": "https://cdn.example.com/sbom.xml"}, + ) + responses.get("https://cdn.example.com/sbom.xml", body=b"content") + dest = tmp_path / "sbom.xml" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + http_client.download_with_hashes(url="https://artifacts.example.com/sbom.xml", dest=dest) + assert dest.read_bytes() == b"content" + + @responses.activate + def test_rejects_redirect_to_internal_ip(self, http_client, tmp_path): + responses.get( + "https://artifacts.example.com/sbom.xml", + status=302, + headers={"Location": "http://169.254.169.254/latest/meta-data/"}, + ) + dest = tmp_path / "sbom.xml" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + with pytest.raises(TeaValidationError, match="private/internal"): + http_client.download_with_hashes(url="https://artifacts.example.com/sbom.xml", dest=dest) + + @responses.activate + def test_rejects_redirect_without_location(self, http_client, tmp_path): + responses.get("https://artifacts.example.com/sbom.xml", status=302, headers={}) + dest = tmp_path / "sbom.xml" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + with pytest.raises(TeaRequestError, match="Redirect without Location"): + http_client.download_with_hashes(url="https://artifacts.example.com/sbom.xml", dest=dest) + + @responses.activate + def test_too_many_redirects(self, http_client, tmp_path): + for i in range(_MAX_DOWNLOAD_REDIRECTS + 1): + responses.get( + f"https://artifacts.example.com/hop{i}", + status=302, + headers={"Location": f"https://artifacts.example.com/hop{i + 1}"}, + ) + dest = tmp_path / "sbom.xml" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + with pytest.raises(TeaConnectionError, match="Too many redirects"): + http_client.download_with_hashes(url="https://artifacts.example.com/hop0", dest=dest) + + +class TestDownloadSizeLimit: + """Download size limit prevents unbounded downloads.""" + + @responses.activate + def test_download_within_limit(self, http_client, tmp_path): + content = b"small" + responses.get("https://artifacts.example.com/small.bin", body=content) + dest = tmp_path / "small.bin" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + http_client.download_with_hashes( + url="https://artifacts.example.com/small.bin", dest=dest, max_download_bytes=1000 + ) + assert dest.read_bytes() == content + + @responses.activate + def test_download_exceeds_limit_raises(self, http_client, tmp_path): + content = b"x" * 2000 + responses.get("https://artifacts.example.com/large.bin", body=content) + dest = tmp_path / "large.bin" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + with pytest.raises(TeaValidationError, match="exceeds size limit"): + http_client.download_with_hashes( + url="https://artifacts.example.com/large.bin", dest=dest, max_download_bytes=1000 + ) + assert not dest.exists() + + @responses.activate + def test_no_limit_by_default(self, http_client, tmp_path): + content = b"x" * 100000 + responses.get("https://artifacts.example.com/big.bin", body=content) + dest = tmp_path / "big.bin" + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + http_client.download_with_hashes(url="https://artifacts.example.com/big.bin", dest=dest) + assert dest.read_bytes() == content + + +class TestTruncationIndicator: + """Error messages indicate when response body is truncated.""" + + @responses.activate + def test_4xx_long_body_shows_truncated(self, http_client, base_url): + long_body = "x" * 300 + responses.get(f"{base_url}/product/abc", body=long_body, status=422) + with pytest.raises(TeaRequestError, match="truncated"): + http_client.get_json("/product/abc") + + @responses.activate + def test_4xx_short_body_no_truncation(self, http_client, base_url): + responses.get(f"{base_url}/product/abc", body="short error", status=422) + with pytest.raises(TeaRequestError) as exc_info: + http_client.get_json("/product/abc") + assert "truncated" not in str(exc_info.value) + + +class TestResponseSizeLimit: + """API response body size limit protection (SEC-04).""" + + @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._max_response_bytes = 5 # Very small limit + responses.get( + "https://api.example.com/v1/product/abc", + json={"uuid": "abc"}, + status=200, + ) + with pytest.raises(TeaValidationError, match="Response too large|exceeds limit"): + client.get_json("/product/abc") + client.close() + + @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._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) + with pytest.raises(TeaValidationError, match="exceeds limit"): + client.get_json("/product/abc") + client.close() + + @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) + result = client.get_json("/product/abc") + assert result == {"uuid": "abc"} + client.close() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integration.py b/tests/integration/test_integration.py similarity index 100% rename from tests/test_integration.py rename to tests/integration/test_integration.py diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 736339a..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,469 +0,0 @@ -import pytest -import responses - -from libtea.client import TeaClient, _validate_path_segment -from libtea.exceptions import TeaDiscoveryError, TeaValidationError -from libtea.models import ( - Artifact, - Collection, - Component, - ComponentReleaseWithCollection, - PaginatedProductReleaseResponse, - PaginatedProductResponse, - Product, - ProductRelease, - Release, -) - - -class TestSearchProducts: - @responses.activate - def test_search_products_by_purl(self, client, base_url): - responses.get( - f"{base_url}/products", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 0, - "pageSize": 100, - "totalResults": 1, - "results": [ - { - "uuid": "abc-123", - "name": "Test Product", - "identifiers": [{"idType": "PURL", "idValue": "pkg:pypi/foo"}], - }, - ], - }, - ) - resp = client.search_products("PURL", "pkg:pypi/foo") - assert isinstance(resp, PaginatedProductResponse) - assert resp.total_results == 1 - assert resp.results[0].name == "Test Product" - request = responses.calls[0].request - assert "idType=PURL" in str(request.url) - assert "idValue=pkg" in str(request.url) - - @responses.activate - def test_search_products_pagination(self, client, base_url): - responses.get( - f"{base_url}/products", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 10, - "pageSize": 25, - "totalResults": 50, - "results": [], - }, - ) - resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25) - request = responses.calls[0].request - assert "pageOffset=10" in str(request.url) - assert "pageSize=25" in str(request.url) - assert resp.page_start_index == 10 - - @responses.activate - def test_search_products_empty(self, client, base_url): - responses.get( - f"{base_url}/products", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 0, - "pageSize": 100, - "totalResults": 0, - "results": [], - }, - ) - resp = client.search_products("PURL", "pkg:pypi/nonexistent") - assert resp.total_results == 0 - assert resp.results == [] - - -class TestSearchProductReleases: - @responses.activate - def test_search_product_releases_by_purl(self, client, base_url): - responses.get( - f"{base_url}/productReleases", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 0, - "pageSize": 100, - "totalResults": 1, - "results": [ - { - "uuid": "rel-1", - "version": "1.0.0", - "createdDate": "2024-01-01T00:00:00Z", - "components": [{"uuid": "comp-1"}], - } - ], - }, - ) - resp = client.search_product_releases("PURL", "pkg:pypi/foo@1.0.0") - assert isinstance(resp, PaginatedProductReleaseResponse) - assert resp.total_results == 1 - assert resp.results[0].version == "1.0.0" - request = responses.calls[0].request - assert "idType=PURL" in str(request.url) - - -class TestProduct: - @responses.activate - def test_get_product(self, client, base_url): - responses.get( - f"{base_url}/product/abc-123", - json={ - "uuid": "abc-123", - "name": "Test Product", - "identifiers": [{"idType": "PURL", "idValue": "pkg:npm/test"}], - }, - ) - product = client.get_product("abc-123") - assert isinstance(product, Product) - assert product.name == "Test Product" - - @responses.activate - def test_get_product_releases(self, client, base_url): - responses.get( - f"{base_url}/product/abc-123/releases", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 0, - "pageSize": 100, - "totalResults": 1, - "results": [ - { - "uuid": "rel-1", - "version": "1.0.0", - "createdDate": "2024-01-01T00:00:00Z", - "components": [{"uuid": "comp-1"}], - } - ], - }, - ) - resp = client.get_product_releases("abc-123") - assert isinstance(resp, PaginatedProductReleaseResponse) - assert resp.total_results == 1 - - -class TestProductRelease: - @responses.activate - def test_get_product_release(self, client, base_url): - responses.get( - f"{base_url}/productRelease/rel-1", - json={ - "uuid": "rel-1", - "version": "1.0.0", - "createdDate": "2024-01-01T00:00:00Z", - "components": [{"uuid": "comp-1"}], - }, - ) - release = client.get_product_release("rel-1") - assert isinstance(release, ProductRelease) - assert release.version == "1.0.0" - - @responses.activate - def test_get_product_release_collection_latest(self, client, base_url): - responses.get( - f"{base_url}/productRelease/rel-1/collection/latest", - json={ - "uuid": "rel-1", - "version": 1, - "artifacts": [], - }, - ) - collection = client.get_product_release_collection_latest("rel-1") - assert isinstance(collection, Collection) - - -class TestComponent: - @responses.activate - def test_get_component(self, client, base_url): - responses.get( - f"{base_url}/component/comp-1", - json={ - "uuid": "comp-1", - "name": "Test Component", - "identifiers": [], - }, - ) - component = client.get_component("comp-1") - assert isinstance(component, Component) - assert component.name == "Test Component" - - @responses.activate - def test_get_component_releases(self, client, base_url): - responses.get( - f"{base_url}/component/comp-1/releases", - json=[ - {"uuid": "cr-1", "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"}, - ], - ) - releases = client.get_component_releases("comp-1") - assert len(releases) == 1 - assert isinstance(releases[0], Release) - - -class TestComponentRelease: - @responses.activate - def test_get_component_release(self, client, base_url): - responses.get( - f"{base_url}/componentRelease/cr-1", - json={ - "release": {"uuid": "cr-1", "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z"}, - "latestCollection": {"uuid": "cr-1", "version": 1, "artifacts": []}, - }, - ) - result = client.get_component_release("cr-1") - assert isinstance(result, ComponentReleaseWithCollection) - assert result.release.version == "1.0.0" - assert result.latest_collection is not None - - @responses.activate - def test_get_component_release_missing_collection_raises(self, client, base_url): - """Per TEA spec, latestCollection is required — missing it should raise.""" - responses.get( - f"{base_url}/componentRelease/cr-2", - json={ - "release": {"uuid": "cr-2", "version": "2.0.0", "createdDate": "2024-01-01T00:00:00Z"}, - }, - ) - with pytest.raises(TeaValidationError, match="Invalid ComponentReleaseWithCollection"): - client.get_component_release("cr-2") - - @responses.activate - def test_get_component_release_collection_latest(self, client, base_url): - responses.get( - f"{base_url}/componentRelease/cr-1/collection/latest", - json={"uuid": "cr-1", "version": 2, "artifacts": []}, - ) - collection = client.get_component_release_collection_latest("cr-1") - assert isinstance(collection, Collection) - assert collection.version == 2 - - @responses.activate - def test_get_component_release_collections(self, client, base_url): - responses.get( - f"{base_url}/componentRelease/cr-1/collections", - json=[ - {"uuid": "cr-1", "version": 1, "artifacts": []}, - {"uuid": "cr-1", "version": 2, "artifacts": []}, - ], - ) - collections = client.get_component_release_collections("cr-1") - assert len(collections) == 2 - - @responses.activate - def test_get_component_release_collection_by_version(self, client, base_url): - responses.get( - f"{base_url}/componentRelease/cr-1/collection/3", - json={"uuid": "cr-1", "version": 3, "artifacts": []}, - ) - collection = client.get_component_release_collection("cr-1", 3) - assert collection.version == 3 - - -class TestArtifact: - @responses.activate - def test_get_artifact(self, client, base_url): - responses.get( - f"{base_url}/artifact/art-1", - json={ - "uuid": "art-1", - "name": "SBOM", - "type": "BOM", - "formats": [ - { - "mediaType": "application/json", - "url": "https://example.com/sbom.json", - "checksums": [], - } - ], - }, - ) - artifact = client.get_artifact("art-1") - assert isinstance(artifact, Artifact) - assert artifact.name == "SBOM" - - -class TestDiscovery: - @responses.activate - def test_discover(self, client, base_url): - tei = "urn:tei:uuid:example.com:d4d9f54a-abcf-11ee-ac79-1a52914d44b" - responses.get( - f"{base_url}/discovery", - json=[ - { - "productReleaseUuid": "d4d9f54a-abcf-11ee-ac79-1a52914d44b", - "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], - } - ], - ) - results = client.discover(tei) - assert len(results) == 1 - assert results[0].product_release_uuid == "d4d9f54a-abcf-11ee-ac79-1a52914d44b" - # Verify TEI is NOT double-encoded (requests auto-encodes params) - request = responses.calls[0].request - assert "tei=" in str(request.url) - - @responses.activate - def test_discover_empty_result(self, client, base_url): - responses.get(f"{base_url}/discovery", json=[]) - results = client.discover("urn:tei:uuid:example.com:d4d9f54a") - assert results == [] - - -class TestFromWellKnown: - @responses.activate - def test_from_well_known_creates_client(self): - responses.get( - "https://example.com/.well-known/tea", - json={ - "schemaVersion": 1, - "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}], - }, - ) - client = TeaClient.from_well_known("example.com") - assert client is not None - client.close() - - @responses.activate - def test_from_well_known_no_compatible_version_raises(self): - responses.get( - "https://example.com/.well-known/tea", - json={ - "schemaVersion": 1, - "endpoints": [{"url": "https://api.example.com", "versions": ["99.0.0"]}], - }, - ) - with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"): - TeaClient.from_well_known("example.com") - - @responses.activate - def test_from_well_known_passes_token(self, base_url): - responses.get( - "https://example.com/.well-known/tea", - json={ - "schemaVersion": 1, - "endpoints": [{"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}], - }, - ) - responses.get( - "https://api.example.com/v0.3.0-beta.2/product/abc", - json={"uuid": "abc", "name": "P", "identifiers": []}, - ) - client = TeaClient.from_well_known("example.com", token="secret") - client.get_product("abc") - assert responses.calls[1].request.headers["authorization"] == "Bearer secret" - client.close() - - -class TestPagination: - @responses.activate - def test_get_product_releases_pagination_params(self, client, base_url): - responses.get( - f"{base_url}/product/abc-123/releases", - json={ - "timestamp": "2024-03-20T15:30:00Z", - "pageStartIndex": 50, - "pageSize": 25, - "totalResults": 200, - "results": [], - }, - ) - resp = client.get_product_releases("abc-123", page_offset=50, page_size=25) - request = responses.calls[0].request - assert "pageOffset=50" in str(request.url) - assert "pageSize=25" in str(request.url) - assert resp.page_start_index == 50 - - -class TestProductReleaseCollections: - @responses.activate - def test_get_product_release_collections(self, client, base_url): - responses.get( - f"{base_url}/productRelease/rel-1/collections", - json=[ - {"uuid": "rel-1", "version": 1, "artifacts": []}, - {"uuid": "rel-1", "version": 2, "artifacts": []}, - ], - ) - collections = client.get_product_release_collections("rel-1") - assert len(collections) == 2 - assert collections[0].version == 1 - - @responses.activate - def test_get_product_release_collection_by_version(self, client, base_url): - responses.get( - f"{base_url}/productRelease/rel-1/collection/5", - json={"uuid": "rel-1", "version": 5, "artifacts": []}, - ) - collection = client.get_product_release_collection("rel-1", 5) - assert collection.version == 5 - - -class TestValidationErrors: - @responses.activate - def test_validate_raises_tea_validation_error(self, client, base_url): - # Missing required fields triggers Pydantic ValidationError → TeaValidationError - responses.get(f"{base_url}/product/abc", json={"bad": "data"}) - with pytest.raises(TeaValidationError, match="Invalid Product response"): - client.get_product("abc") - - @responses.activate - def test_validate_list_raises_tea_validation_error(self, client, base_url): - # List with invalid items triggers Pydantic ValidationError → TeaValidationError - responses.get( - f"{base_url}/component/comp-1/releases", - json=[{"bad": "data"}], - ) - with pytest.raises(TeaValidationError, match="Invalid Release response"): - client.get_component_releases("comp-1") - - @responses.activate - def test_validate_list_rejects_non_list_response(self, client, base_url): - responses.get(f"{base_url}/component/comp-1/releases", json={"not": "a list"}) - with pytest.raises(TeaValidationError, match="Expected list"): - client.get_component_releases("comp-1") - - -class TestValidatePathSegment: - def test_accepts_uuid(self): - assert _validate_path_segment("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" - - def test_accepts_alphanumeric(self): - assert _validate_path_segment("abc123") == "abc123" - - @pytest.mark.parametrize( - "value", - [ - "../../etc/passwd", - "abc/def", - "abc def", - "abc?query=1", - "abc#fragment", - "abc@host", - "abc.def", - "", - "a" * 129, - "abc\x00def", - ], - ) - def test_rejects_unsafe_values(self, value): - with pytest.raises(TeaValidationError, match="Invalid uuid"): - _validate_path_segment(value) - - def test_error_message_includes_guidance(self): - with pytest.raises(TeaValidationError, match="alphanumeric characters and hyphens"): - _validate_path_segment("../traversal") - - -class TestContextManager: - @responses.activate - def test_client_as_context_manager(self, base_url): - responses.get( - f"{base_url}/component/c1", - json={"uuid": "c1", "name": "C1", "identifiers": []}, - ) - with TeaClient(base_url=base_url) as client: - component = client.get_component("c1") - assert component.name == "C1" diff --git a/tests/test_discovery.py b/tests/test_discovery.py deleted file mode 100644 index c1db9d0..0000000 --- a/tests/test_discovery.py +++ /dev/null @@ -1,346 +0,0 @@ -import pytest -import requests -import responses -from pydantic import ValidationError - -from libtea.discovery import _SemVer, fetch_well_known, parse_tei, select_endpoint -from libtea.exceptions import TeaDiscoveryError -from libtea.models import TeaEndpoint, TeaWellKnown, TeiType - - -class TestParseTei: - def test_uuid_tei(self): - tei = "urn:tei:uuid:products.example.com:d4d9f54a-abcf-11ee-ac79-1a52914d44b1" - tei_type, domain, identifier = parse_tei(tei) - assert tei_type == "uuid" - assert domain == "products.example.com" - assert identifier == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" - - def test_purl_tei(self): - tei = "urn:tei:purl:cyclonedx.org:pkg:pypi/cyclonedx-python-lib@8.4.0" - tei_type, domain, identifier = parse_tei(tei) - assert tei_type == "purl" - assert domain == "cyclonedx.org" - assert identifier == "pkg:pypi/cyclonedx-python-lib@8.4.0" - - def test_hash_tei(self): - tei = "urn:tei:hash:cyclonedx.org:SHA256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9" - tei_type, domain, identifier = parse_tei(tei) - assert tei_type == "hash" - assert domain == "cyclonedx.org" - assert identifier == "SHA256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9" - - def test_invalid_tei_no_urn_prefix(self): - with pytest.raises(TeaDiscoveryError, match="Invalid TEI"): - parse_tei("not-a-tei") - - def test_invalid_tei_wrong_prefix(self): - with pytest.raises(TeaDiscoveryError, match="Invalid TEI"): - parse_tei("urn:other:uuid:example.com:123") - - def test_invalid_tei_too_few_parts(self): - with pytest.raises(TeaDiscoveryError, match="Invalid TEI"): - parse_tei("urn:tei:uuid") - - def test_invalid_tei_empty_string(self): - with pytest.raises(TeaDiscoveryError, match="Invalid TEI"): - parse_tei("") - - def test_invalid_tei_unknown_type(self): - with pytest.raises(TeaDiscoveryError, match="Invalid TEI type"): - parse_tei("urn:tei:unknown:example.com:some-id") - - @pytest.mark.parametrize("tei_type", [e.value for e in TeiType]) - def test_all_valid_tei_types(self, tei_type): - result_type, domain, identifier = parse_tei(f"urn:tei:{tei_type}:example.com:some-id") - assert result_type == tei_type - assert domain == "example.com" - assert identifier == "some-id" - - def test_invalid_tei_empty_domain(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - parse_tei("urn:tei:uuid::some-id") - - def test_invalid_tei_bad_domain_format(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - parse_tei("urn:tei:uuid:-invalid.com:some-id") - - def test_invalid_tei_domain_with_underscore(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - parse_tei("urn:tei:uuid:bad_domain.com:some-id") - - def test_valid_tei_subdomain(self): - _, domain, _ = parse_tei("urn:tei:uuid:products.tea.example.com:some-id") - assert domain == "products.tea.example.com" - - def test_valid_tei_single_label_domain(self): - _, domain, _ = parse_tei("urn:tei:uuid:localhost:some-id") - assert domain == "localhost" - - def test_tei_with_slash_in_purl_identifier(self): - tei = "urn:tei:purl:cyclonedx.org:pkg:maven/org.apache/log4j@2.24.3" - tei_type, domain, identifier = parse_tei(tei) - assert tei_type == "purl" - assert domain == "cyclonedx.org" - assert identifier == "pkg:maven/org.apache/log4j@2.24.3" - - -class TestFetchWellKnown: - @responses.activate - def test_fetch_well_known_success(self): - responses.get( - "https://example.com/.well-known/tea", - json={ - "schemaVersion": 1, - "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}], - }, - ) - wk = fetch_well_known("example.com") - assert wk.schema_version == 1 - assert len(wk.endpoints) == 1 - - @responses.activate - def test_fetch_well_known_sends_user_agent(self): - responses.get( - "https://example.com/.well-known/tea", - json={ - "schemaVersion": 1, - "endpoints": [{"url": "https://api.example.com", "versions": ["1.0.0"]}], - }, - ) - fetch_well_known("example.com") - ua = responses.calls[0].request.headers["user-agent"] - assert ua.startswith("py-libtea/") - assert "hello@sbomify.com" in ua - - @responses.activate - def test_fetch_well_known_404_raises_discovery_error(self): - responses.get("https://example.com/.well-known/tea", status=404) - with pytest.raises(TeaDiscoveryError, match="HTTP 404"): - fetch_well_known("example.com") - - @responses.activate - def test_fetch_well_known_connection_error(self): - responses.get("https://example.com/.well-known/tea", body=requests.ConnectionError("refused")) - with pytest.raises(TeaDiscoveryError, match="Failed to connect"): - fetch_well_known("example.com") - - @responses.activate - def test_fetch_well_known_timeout_error(self): - responses.get("https://example.com/.well-known/tea", body=requests.Timeout("timed out")) - with pytest.raises(TeaDiscoveryError, match="Failed to connect"): - fetch_well_known("example.com") - - @responses.activate - def test_fetch_well_known_500_raises_discovery_error(self): - responses.get("https://example.com/.well-known/tea", status=500) - with pytest.raises(TeaDiscoveryError): - fetch_well_known("example.com") - - def test_fetch_well_known_empty_domain_raises(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - fetch_well_known("") - - def test_fetch_well_known_invalid_domain_raises(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - fetch_well_known("-bad.com") - - def test_fetch_well_known_underscore_domain_raises(self): - with pytest.raises(TeaDiscoveryError, match="Invalid domain"): - fetch_well_known("bad_domain.com") - - @responses.activate - def test_fetch_well_known_request_exception(self): - responses.get("https://example.com/.well-known/tea", body=requests.exceptions.TooManyRedirects("too many")) - with pytest.raises(TeaDiscoveryError, match="HTTP error"): - fetch_well_known("example.com") - - @responses.activate - def test_fetch_well_known_non_json_raises_discovery_error(self): - responses.get("https://example.com/.well-known/tea", body="not json") - 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"}) - with pytest.raises(TeaDiscoveryError, match="Invalid .well-known/tea"): - fetch_well_known("example.com") - - -class TestSelectEndpoint: - def _make_well_known(self, endpoints: list[dict]) -> TeaWellKnown: - return TeaWellKnown( - schema_version=1, - endpoints=[TeaEndpoint(**ep) for ep in endpoints], - ) - - def test_selects_matching_version(self): - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["1.0.0"]}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://api.example.com" - - def test_selects_highest_priority(self): - wk = self._make_well_known( - [ - {"url": "https://low.example.com", "versions": ["1.0.0"], "priority": 0.5}, - {"url": "https://high.example.com", "versions": ["1.0.0"], "priority": 1.0}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://high.example.com" - - def test_no_matching_version_raises(self): - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["2.0.0"]}, - ] - ) - with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"): - select_endpoint(wk, "1.0.0") - - def test_prefers_highest_matching_version(self): - wk = self._make_well_known( - [ - {"url": "https://old.example.com", "versions": ["0.1.0"]}, - {"url": "https://new.example.com", "versions": ["0.1.0", "1.0.0"]}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://new.example.com" - - def test_empty_endpoints_rejected_by_model(self): - """TeaWellKnown enforces min_length=1 on endpoints per spec.""" - with pytest.raises(ValidationError): - TeaWellKnown(schema_version=1, endpoints=[]) - - def test_none_priority_defaults_to_1(self): - """Endpoint without priority defaults to 1.0 (highest), matching spec default.""" - wk = self._make_well_known( - [ - {"url": "https://none-priority.example.com", "versions": ["1.0.0"]}, - {"url": "https://low-priority.example.com", "versions": ["1.0.0"], "priority": 0.5}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://none-priority.example.com" - - def test_semver_matches_without_patch(self): - """Version '1.0' in endpoint should match client version '1.0.0'.""" - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["1.0"]}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://api.example.com" - - def test_semver_matches_with_prerelease(self): - """Pre-release versions match exactly.""" - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["0.3.0-beta.2"]}, - ] - ) - ep = select_endpoint(wk, "0.3.0-beta.2") - assert ep.url == "https://api.example.com" - - def test_semver_prerelease_does_not_match_release(self): - """Pre-release '1.0.0-beta.1' should not match '1.0.0'.""" - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["1.0.0-beta.1"]}, - ] - ) - with pytest.raises(TeaDiscoveryError, match="No compatible endpoint"): - select_endpoint(wk, "1.0.0") - - def test_invalid_semver_in_endpoint_skipped(self): - """Invalid version strings in endpoint are silently skipped.""" - wk = self._make_well_known( - [ - {"url": "https://api.example.com", "versions": ["not-semver", "1.0.0"]}, - ] - ) - ep = select_endpoint(wk, "1.0.0") - assert ep.url == "https://api.example.com" - - def test_priority_out_of_range_rejected(self): - """Priority > 1.0 should be rejected by model validation.""" - with pytest.raises(ValidationError): - TeaEndpoint(url="https://api.example.com", versions=["1.0.0"], priority=2.0) - - def test_empty_versions_rejected(self): - """Endpoint with empty versions list should be rejected by model validation.""" - with pytest.raises(ValidationError): - TeaEndpoint(url="https://api.example.com", versions=[]) - - -class TestSemVer: - def test_parse_basic(self): - v = _SemVer("1.2.3") - assert v.major == 1 - assert v.minor == 2 - assert v.patch == 3 - assert v.pre == () - - def test_parse_without_patch(self): - v = _SemVer("1.0") - assert v.major == 1 - assert v.minor == 0 - assert v.patch == 0 - - def test_parse_with_prerelease(self): - v = _SemVer("0.3.0-beta.2") - assert v.major == 0 - assert v.minor == 3 - assert v.patch == 0 - assert v.pre == ("beta", 2) - - def test_equality_with_and_without_patch(self): - assert _SemVer("1.0") == _SemVer("1.0.0") - - def test_ordering_major(self): - assert _SemVer("1.0.0") < _SemVer("2.0.0") - - def test_ordering_minor(self): - assert _SemVer("1.0.0") < _SemVer("1.1.0") - - def test_ordering_patch(self): - assert _SemVer("1.0.0") < _SemVer("1.0.1") - - def test_prerelease_lower_than_release(self): - assert _SemVer("1.0.0-alpha") < _SemVer("1.0.0") - - def test_prerelease_ordering(self): - """SemVer spec example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0""" - versions = [ - "1.0.0-alpha", - "1.0.0-alpha.1", - "1.0.0-alpha.beta", - "1.0.0-beta", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", - ] - parsed = [_SemVer(v) for v in versions] - for i in range(len(parsed) - 1): - assert parsed[i] < parsed[i + 1], f"{versions[i]} should be < {versions[i + 1]}" - - def test_numeric_prerelease_less_than_alpha(self): - """Numeric identifiers have lower precedence than alphanumeric.""" - assert _SemVer("1.0.0-1") < _SemVer("1.0.0-alpha") - - def test_invalid_semver_raises(self): - with pytest.raises(ValueError, match="Invalid SemVer"): - _SemVer("not-a-version") - - def test_str_repr(self): - v = _SemVer("1.2.3-beta.1") - assert str(v) == "1.2.3-beta.1" - assert repr(v) == "_SemVer('1.2.3-beta.1')" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli_entry.py b/tests/unit/test_cli_entry.py new file mode 100644 index 0000000..98a9690 --- /dev/null +++ b/tests/unit/test_cli_entry.py @@ -0,0 +1,21 @@ +"""Tests for libtea._cli_entry — does NOT require typer to be installed.""" + +import sys +from unittest.mock import patch + +import pytest + +from libtea._cli_entry import main + + +class TestCliEntryMissingTyper: + """Exercise the ImportError branch (lines 10-12) in-process.""" + + def test_prints_install_hint_and_exits(self, capsys): + """When libtea.cli cannot be imported, main() prints a help message and exits 1.""" + with patch.dict(sys.modules, {"libtea.cli": None}): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "pip install libtea[cli]" in captured.err diff --git a/tests/test_exceptions.py b/tests/unit/test_exceptions.py similarity index 100% rename from tests/test_exceptions.py rename to tests/unit/test_exceptions.py diff --git a/tests/unit/test_hashing.py b/tests/unit/test_hashing.py new file mode 100644 index 0000000..2a21fea --- /dev/null +++ b/tests/unit/test_hashing.py @@ -0,0 +1,60 @@ +import hashlib + +import pytest +import responses + +from libtea._hashing import _build_hashers +from libtea._http import TeaHttpClient +from libtea.exceptions import TeaChecksumError + + +class TestBuildHashers: + def test_blake3_raises(self): + with pytest.raises(TeaChecksumError, match="BLAKE3"): + _build_hashers(["BLAKE3"]) + + def test_unknown_algorithm_raises(self): + with pytest.raises(TeaChecksumError, match="Unsupported checksum algorithm"): + _build_hashers(["UNKNOWN-ALG"]) + + @pytest.mark.parametrize( + "algorithm", + ["MD5", "SHA-1", "SHA-256", "SHA-384", "SHA-512", "SHA3-256", "SHA3-384", "SHA3-512"], + ) + def test_standard_algorithms(self, algorithm): + hashers = _build_hashers([algorithm]) + assert algorithm in hashers + # Verify the hasher produces a hex digest + hashers[algorithm].update(b"test") + assert len(hashers[algorithm].hexdigest()) > 0 + + @pytest.mark.parametrize("algorithm,digest_size", [("BLAKE2b-256", 32), ("BLAKE2b-384", 48), ("BLAKE2b-512", 64)]) + def test_blake2b_variants(self, algorithm, digest_size): + hashers = _build_hashers([algorithm]) + assert algorithm in hashers + hashers[algorithm].update(b"test") + # BLAKE2b hex digest length = digest_size * 2 + assert len(hashers[algorithm].hexdigest()) == digest_size * 2 + + @responses.activate + def test_all_algorithms_produce_correct_digests(self, tmp_path): + """End-to-end: download with each algorithm and verify the digest is correct.""" + content = b"algorithm test content" + url = "https://artifacts.example.com/test.bin" + responses.get(url, body=content) + + client = TeaHttpClient(base_url="https://api.example.com") + all_algs = ["MD5", "SHA-1", "SHA-256", "SHA-384", "SHA-512", "SHA3-256", "SHA3-384", "SHA3-512"] + + dest = tmp_path / "test.bin" + digests = client.download_with_hashes(url=url, dest=dest, algorithms=all_algs) + client.close() + + assert digests["MD5"] == hashlib.md5(content).hexdigest() + assert digests["SHA-1"] == hashlib.sha1(content).hexdigest() + assert digests["SHA-256"] == hashlib.sha256(content).hexdigest() + assert digests["SHA-384"] == hashlib.sha384(content).hexdigest() + assert digests["SHA-512"] == hashlib.sha512(content).hexdigest() + assert digests["SHA3-256"] == hashlib.new("sha3_256", content).hexdigest() + assert digests["SHA3-384"] == hashlib.new("sha3_384", content).hexdigest() + assert digests["SHA3-512"] == hashlib.new("sha3_512", content).hexdigest() diff --git a/tests/test_models.py b/tests/unit/test_models.py similarity index 60% rename from tests/test_models.py rename to tests/unit/test_models.py index 7248ab9..ec59988 100644 --- a/tests/test_models.py +++ b/tests/unit/test_models.py @@ -2,10 +2,14 @@ from pydantic import ValidationError from libtea.models import ( + CLE, ArtifactFormat, ArtifactType, Checksum, ChecksumAlgorithm, + CLEEvent, + CLEEventType, + CLEVersionSpecifier, Collection, CollectionBelongsTo, CollectionUpdateReasonType, @@ -24,7 +28,6 @@ def test_identifier_type_values(self): assert IdentifierType.CPE == "CPE" assert IdentifierType.TEI == "TEI" assert IdentifierType.PURL == "PURL" - assert IdentifierType.UDI == "UDI" def test_checksum_algorithm_values(self): assert ChecksumAlgorithm.SHA_256 == "SHA-256" @@ -139,9 +142,10 @@ def test_checksum_rejects_unknown_algorithm(self): with pytest.raises(ValidationError): Checksum.model_validate({"algType": "CRC32", "algValue": "aabbcc"}) - def test_identifier_rejects_unknown_type(self): - with pytest.raises(ValidationError): - Identifier.model_validate({"idType": "SPDXID", "idValue": "some-value"}) + def test_identifier_accepts_unknown_type(self): + """Forward-compatible: unknown identifier types pass through as strings.""" + ident = Identifier.model_validate({"idType": "SPDXID", "idValue": "some-value"}) + assert ident.id_type == "SPDXID" def test_checksum_rejects_missing_algorithm_type(self): with pytest.raises(ValidationError): @@ -222,14 +226,15 @@ def test_product_from_json(self): assert len(product.identifiers) == 2 assert product.identifiers[0].id_type == IdentifierType.CPE - def test_product_with_udi_identifier(self): + def test_product_with_unknown_identifier_type(self): + """Forward-compatible: unknown identifier types pass through as plain strings.""" data = { "uuid": "abc-123", "name": "Medical Device", "identifiers": [{"idType": "UDI", "idValue": "00123456789012"}], } product = Product.model_validate(data) - assert product.identifiers[0].id_type == IdentifierType.UDI + assert product.identifiers[0].id_type == "UDI" assert product.identifiers[0].id_value == "00123456789012" @@ -304,8 +309,8 @@ def test_release_minimal_fields(self): assert release.release_date is None assert release.pre_release is None assert release.component is None - assert release.distributions == [] - assert release.identifiers == [] + assert release.distributions == () + assert release.identifiers == () def test_collection_minimal_fields(self): data = {"uuid": "c-1", "version": 1} @@ -313,7 +318,23 @@ def test_collection_minimal_fields(self): assert collection.date is None assert collection.belongs_to is None assert collection.update_reason is None - assert collection.artifacts == [] + assert collection.artifacts == () + + def test_collection_all_fields_optional(self): + """Per TEA spec, all Collection fields are optional.""" + collection = Collection.model_validate({}) + assert collection.uuid is None + assert collection.version is None + assert collection.artifacts == () + + def test_collection_version_rejects_zero(self): + """TEA spec says versions start with 1.""" + with pytest.raises(ValidationError): + Collection.model_validate({"version": 0}) + + def test_collection_version_rejects_negative(self): + with pytest.raises(ValidationError): + Collection.model_validate({"version": -1}) def test_artifact_format_minimal_fields(self): data = { @@ -323,7 +344,7 @@ def test_artifact_format_minimal_fields(self): fmt = ArtifactFormat.model_validate(data) assert fmt.description is None assert fmt.signature_url is None - assert fmt.checksums == [] + assert fmt.checksums == () def test_paginated_product_response_empty_results(self): data = { @@ -335,7 +356,7 @@ def test_paginated_product_response_empty_results(self): } resp = PaginatedProductResponse.model_validate(data) assert resp.total_results == 0 - assert resp.results == [] + assert resp.results == () class TestPaginatedResponse: @@ -356,3 +377,205 @@ def test_paginated_product_response(self): resp = PaginatedProductResponse.model_validate(data) assert resp.total_results == 1 assert resp.results[0].name == "Apache Log4j 2" + + +class TestCLEEventType: + @pytest.mark.parametrize( + "value", + [ + "released", + "endOfDevelopment", + "endOfSupport", + "endOfLife", + "endOfDistribution", + "endOfMarketing", + "supersededBy", + "componentRenamed", + "withdrawn", + ], + ) + def test_all_event_types(self, value): + assert CLEEventType(value) == value + + +class TestCLEModels: + def test_released_event(self): + event = CLEEvent.model_validate( + { + "id": 1, + "type": "released", + "effective": "2024-01-01T00:00:00Z", + "published": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "license": "Apache-2.0", + } + ) + assert event.id == 1 + assert event.type == CLEEventType.RELEASED + assert event.version == "1.0.0" + assert event.license == "Apache-2.0" + + def test_end_of_support_event(self): + event = CLEEvent.model_validate( + { + "id": 3, + "type": "endOfSupport", + "effective": "2025-06-01T00:00:00Z", + "published": "2025-01-01T00:00:00Z", + "versions": [{"range": "vers:npm/>=1.0.0|<2.0.0"}], + "supportId": "standard", + } + ) + assert event.type == CLEEventType.END_OF_SUPPORT + assert event.support_id == "standard" + assert len(event.versions) == 1 + assert event.versions[0].range == "vers:npm/>=1.0.0|<2.0.0" + + def test_withdrawn_event(self): + event = CLEEvent.model_validate( + { + "id": 5, + "type": "withdrawn", + "effective": "2025-03-01T00:00:00Z", + "published": "2025-03-01T00:00:00Z", + "eventId": 1, + "reason": "Incorrect release date", + } + ) + assert event.type == CLEEventType.WITHDRAWN + assert event.event_id == 1 + assert event.reason == "Incorrect release date" + + def test_component_renamed_event(self): + event = CLEEvent.model_validate( + { + "id": 4, + "type": "componentRenamed", + "effective": "2025-01-01T00:00:00Z", + "published": "2025-01-01T00:00:00Z", + "identifiers": [{"idType": "PURL", "idValue": "pkg:pypi/new-name@1.0.0"}], + } + ) + assert event.type == CLEEventType.COMPONENT_RENAMED + assert len(event.identifiers) == 1 + + def test_full_cle_document(self): + cle = CLE.model_validate( + { + "events": [ + { + "id": 2, + "type": "endOfDevelopment", + "effective": "2025-01-01T00:00:00Z", + "published": "2024-06-01T00:00:00Z", + "versions": [{"version": "1.0.0"}], + "supportId": "standard", + }, + { + "id": 1, + "type": "released", + "effective": "2024-01-01T00:00:00Z", + "published": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "license": "Apache-2.0", + }, + ], + "definitions": { + "support": [ + {"id": "standard", "description": "Standard support", "url": "https://example.com/support"} + ] + }, + } + ) + assert len(cle.events) == 2 + assert cle.definitions is not None + assert len(cle.definitions.support) == 1 + + def test_cle_without_definitions(self): + cle = CLE.model_validate( + { + "events": [ + { + "id": 1, + "type": "released", + "effective": "2024-01-01T00:00:00Z", + "published": "2024-01-01T00:00:00Z", + } + ] + } + ) + assert cle.definitions is None + + def test_cle_event_missing_required_fields(self): + with pytest.raises(ValidationError): + CLEEvent.model_validate({"id": 1}) + + def test_superseded_by_event(self): + event = CLEEvent.model_validate( + { + "id": 6, + "type": "supersededBy", + "effective": "2025-06-01T00:00:00Z", + "published": "2025-05-01T00:00:00Z", + "versions": [{"range": "vers:npm/>=1.0.0|<2.0.0"}], + "supersededByVersion": "2.0.0", + } + ) + assert event.type == CLEEventType.SUPERSEDED_BY + assert event.superseded_by_version == "2.0.0" + assert len(event.versions) == 1 + + def test_end_of_life_event(self): + event = CLEEvent.model_validate( + { + "id": 7, + "type": "endOfLife", + "effective": "2026-01-01T00:00:00Z", + "published": "2025-06-01T00:00:00Z", + "versions": [{"version": "1.0.0"}], + "supportId": "standard", + } + ) + assert event.type == CLEEventType.END_OF_LIFE + assert event.support_id == "standard" + + def test_end_of_distribution_event(self): + event = CLEEvent.model_validate( + { + "id": 8, + "type": "endOfDistribution", + "effective": "2026-03-01T00:00:00Z", + "published": "2025-12-01T00:00:00Z", + "versions": [{"version": "1.0.0"}, {"range": "vers:npm/>=0.9.0|<1.0.0"}], + } + ) + assert event.type == CLEEventType.END_OF_DISTRIBUTION + assert len(event.versions) == 2 + + def test_end_of_marketing_event(self): + event = CLEEvent.model_validate( + { + "id": 9, + "type": "endOfMarketing", + "effective": "2026-06-01T00:00:00Z", + "published": "2026-01-01T00:00:00Z", + "versions": [{"version": "1.0.0"}], + "description": "No longer marketed", + } + ) + assert event.type == CLEEventType.END_OF_MARKETING + assert event.description == "No longer marketed" + + def test_version_specifier_with_version(self): + vs = CLEVersionSpecifier.model_validate({"version": "1.0.0"}) + assert vs.version == "1.0.0" + assert vs.range is None + + def test_version_specifier_with_range(self): + vs = CLEVersionSpecifier.model_validate({"range": "vers:npm/>=1.0.0|<2.0.0"}) + assert vs.version is None + assert vs.range == "vers:npm/>=1.0.0|<2.0.0" + + def test_version_specifier_empty_rejected(self): + with pytest.raises(ValidationError, match="at least one"): + CLEVersionSpecifier.model_validate({}) diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..20705e1 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,198 @@ +from unittest.mock import patch + +import pytest + +from libtea._security import _is_internal_ip, _validate_download_url, _validate_resolved_ips +from libtea.exceptions import TeaValidationError + + +class TestValidateDownloadUrl: + def test_rejects_file_scheme(self): + with pytest.raises(TeaValidationError, match="http or https scheme"): + _validate_download_url("file:///etc/passwd") + + def test_rejects_ftp_scheme(self): + with pytest.raises(TeaValidationError, match="http or https scheme"): + _validate_download_url("ftp://evil.com/file") + + def test_rejects_data_scheme(self): + with pytest.raises(TeaValidationError, match="http or https scheme"): + _validate_download_url("data:text/html,

hi

") + + def test_rejects_gopher_scheme(self): + with pytest.raises(TeaValidationError, match="http or https scheme"): + _validate_download_url("gopher://evil.com") + + def test_rejects_unknown_scheme(self): + with pytest.raises(TeaValidationError, match="http or https scheme"): + _validate_download_url("javascript:alert(1)") + + def test_rejects_missing_hostname(self): + with pytest.raises(TeaValidationError, match="must include a hostname"): + _validate_download_url("http:///path/only") + + def test_accepts_http(self): + _validate_download_url("http://example.com/file.xml") + + def test_accepts_https(self): + _validate_download_url("https://cdn.example.com/sbom.json") + + +class TestSsrfProtection: + """Download URL must not target private/internal networks.""" + + @pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/file.xml", + "http://10.0.0.1/file.xml", + "http://172.16.0.1/file.xml", + "http://192.168.1.1/file.xml", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/file.xml", + "http://[::1]/file.xml", + "http://localhost/file.xml", + "http://localhost.localdomain/file.xml", + "http://metadata.google.internal/computeMetadata/v1/", + ], + ) + def test_rejects_internal_urls(self, url): + with pytest.raises(TeaValidationError): + _validate_download_url(url) + + def test_rejects_cgnat_ip(self): + """P0-1: CGNAT range (100.64.0.0/10) must be blocked.""" + with pytest.raises(TeaValidationError, match="private/internal"): + _validate_download_url("http://100.64.0.1/file.xml") + + def test_accepts_public_url(self): + with patch("libtea._security.socket.getaddrinfo", return_value=[]): + _validate_download_url("https://cdn.example.com/sbom.json") + + def test_accepts_public_ip(self): + _validate_download_url("https://8.8.8.8/file.xml") + + +class TestIsInternalIp: + """Tests for the _is_internal_ip helper.""" + + def test_cgnat_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv4Address("100.64.0.1")) + assert _is_internal_ip(ipaddress.IPv4Address("100.127.255.254")) + + def test_public_ip_not_internal(self): + import ipaddress + + assert not _is_internal_ip(ipaddress.IPv4Address("8.8.8.8")) + assert not _is_internal_ip(ipaddress.IPv4Address("93.184.216.34")) + + def test_loopback_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv4Address("127.0.0.1")) + + def test_link_local_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv4Address("169.254.169.254")) + + def test_ipv6_loopback_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv6Address("::1")) + + def test_unspecified_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv4Address("0.0.0.0")) + assert _is_internal_ip(ipaddress.IPv6Address("::")) + + def test_multicast_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv4Address("224.0.0.1")) + assert _is_internal_ip(ipaddress.IPv6Address("ff02::1")) + + def test_ipv4_mapped_ipv6_cgnat_is_internal(self): + """SEC-01: IPv4-mapped IPv6 CGNAT addresses must be blocked.""" + import ipaddress + + assert _is_internal_ip(ipaddress.IPv6Address("::ffff:100.64.0.1")) + assert _is_internal_ip(ipaddress.IPv6Address("::ffff:100.127.255.254")) + + def test_ipv4_mapped_ipv6_private_is_internal(self): + import ipaddress + + assert _is_internal_ip(ipaddress.IPv6Address("::ffff:10.0.0.1")) + assert _is_internal_ip(ipaddress.IPv6Address("::ffff:169.254.169.254")) + + def test_ipv4_mapped_ipv6_public_not_internal(self): + import ipaddress + + assert not _is_internal_ip(ipaddress.IPv6Address("::ffff:8.8.8.8")) + + def test_skips_unparseable_sockaddr(self): + """Non-IP address entries in getaddrinfo results are silently skipped.""" + fake_addr = [(1, 1, 0, "", ("/var/run/some.sock",))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + _validate_resolved_ips("unix-socket.example.com") # should not raise + + +class TestDnsRebindingProtection: + """DNS rebinding protection via hostname resolution check.""" + + def test_rejects_hostname_resolving_to_loopback(self): + fake_addr = [(2, 1, 6, "", ("127.0.0.1", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + with pytest.raises(TeaValidationError, match="resolves to private/internal IP"): + _validate_resolved_ips("evil-rebind.example.com") + + def test_rejects_hostname_resolving_to_private(self): + fake_addr = [(2, 1, 6, "", ("10.0.0.1", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + with pytest.raises(TeaValidationError, match="resolves to private/internal IP"): + _validate_resolved_ips("evil-rebind.example.com") + + def test_rejects_hostname_resolving_to_link_local(self): + fake_addr = [(2, 1, 6, "", ("169.254.169.254", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + with pytest.raises(TeaValidationError, match="resolves to private/internal IP"): + _validate_resolved_ips("evil-metadata.example.com") + + def test_accepts_hostname_resolving_to_public_ip(self): + fake_addr = [(2, 1, 6, "", ("93.184.216.34", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + _validate_resolved_ips("cdn.example.com") # should not raise + + def test_rejects_hostname_resolving_to_cgnat(self): + """P0-1: CGNAT range via DNS rebinding must be blocked.""" + fake_addr = [(2, 1, 6, "", ("100.64.0.1", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + with pytest.raises(TeaValidationError, match="resolves to private/internal IP"): + _validate_resolved_ips("evil-cgnat.example.com") + + def test_dns_failure_logs_warning(self, caplog): + """DNS failure should log a warning, not silently pass.""" + import logging + import socket + + with caplog.at_level(logging.WARNING, logger="libtea"): + with patch("libtea._security.socket.getaddrinfo", side_effect=socket.gaierror("NXDOMAIN")): + _validate_resolved_ips("nonexistent.example.com") + assert "DNS resolution failed" in caplog.text + + def test_dns_failure_is_ignored(self): + """If DNS resolution fails, let the actual request handle it.""" + import socket + + with patch("libtea._security.socket.getaddrinfo", side_effect=socket.gaierror("NXDOMAIN")): + _validate_resolved_ips("nonexistent.example.com") # should not raise + + def test_validate_download_url_calls_dns_check(self): + """Non-IP hostnames trigger DNS resolution check.""" + fake_addr = [(2, 1, 6, "", ("10.0.0.1", 0))] + with patch("libtea._security.socket.getaddrinfo", return_value=fake_addr): + with pytest.raises(TeaValidationError, match="resolves to private/internal IP"): + _validate_download_url("https://evil-rebind.example.com/file.xml") diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 0000000..d4320a4 --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,122 @@ +import pytest + +from libtea._validation import ( + _MAX_PAGE_SIZE, + _validate_collection_version, + _validate_page_offset, + _validate_page_size, + _validate_path_segment, +) +from libtea.exceptions import TeaValidationError + + +class TestValidatePathSegment: + def test_accepts_uuid(self): + assert _validate_path_segment("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + + def test_normalizes_uppercase_uuid(self): + assert _validate_path_segment("D4D9F54A-ABCF-11EE-AC79-1A52914D44B1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + + def test_normalizes_uuid_without_hyphens(self): + assert _validate_path_segment("d4d9f54aabcf11eeac791a52914d44b1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + + @pytest.mark.parametrize( + "value", + [ + "../../etc/passwd", + "abc-123", + "not-a-uuid", + "", + "abc\x00def", + ], + ) + def test_rejects_unsafe_values(self, value): + with pytest.raises(TeaValidationError, match="Invalid uuid"): + _validate_path_segment(value) + + def test_error_message_includes_guidance(self): + with pytest.raises(TeaValidationError, match="valid UUID"): + _validate_path_segment("../traversal") + + +class TestPageSizeValidation: + """page_size parameter is validated in search/paginated methods.""" + + def test_validate_page_size_rejects_zero(self): + with pytest.raises(TeaValidationError, match="page_size must be between 1"): + _validate_page_size(0) + + def test_validate_page_size_rejects_negative(self): + with pytest.raises(TeaValidationError, match="page_size must be between 1"): + _validate_page_size(-1) + + def test_validate_page_size_rejects_too_large(self): + with pytest.raises(TeaValidationError, match="page_size must be between 1"): + _validate_page_size(_MAX_PAGE_SIZE + 1) + + def test_validate_page_size_accepts_one(self): + _validate_page_size(1) # should not raise + + def test_validate_page_size_accepts_max(self): + _validate_page_size(_MAX_PAGE_SIZE) # should not raise + + def test_search_products_rejects_bad_page_size(self, client): + with pytest.raises(TeaValidationError, match="page_size"): + client.search_products("PURL", "pkg:pypi/foo", page_size=0) + + def test_get_product_releases_rejects_bad_page_size(self, client): + with pytest.raises(TeaValidationError, match="page_size"): + client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_size=-1) + + def test_search_product_releases_rejects_bad_page_size(self, client): + with pytest.raises(TeaValidationError, match="page_size"): + 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 + + 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) + + 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) + + 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) + + +class TestCollectionVersionValidation: + """Collection version parameter is validated before making API calls.""" + + def test_validate_collection_version_rejects_zero(self): + with pytest.raises(TeaValidationError, match="Collection version must be >= 1"): + _validate_collection_version(0) + + def test_validate_collection_version_rejects_negative(self): + with pytest.raises(TeaValidationError, match="Collection version must be >= 1"): + _validate_collection_version(-1) + + def test_validate_collection_version_accepts_one(self): + _validate_collection_version(1) # should not raise + + def test_get_product_release_collection_rejects_zero(self, client): + with pytest.raises(TeaValidationError, match="Collection version"): + client.get_product_release_collection("b2c3d4e5-f6a7-8901-bcde-f12345678901", 0) + + def test_get_component_release_collection_rejects_zero(self, client): + with pytest.raises(TeaValidationError, match="Collection version"): + client.get_component_release_collection("d4e5f6a7-b8c9-0123-defa-234567890123", 0) diff --git a/tests/test_version.py b/tests/unit/test_version.py similarity index 100% rename from tests/test_version.py rename to tests/unit/test_version.py diff --git a/uv.lock b/uv.lock index 84a4d4b..fbe54ec 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -102,6 +111,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -260,37 +281,198 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + [[package]] name = "libtea" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, { name = "requests" }, + { name = "semver" }, +] + +[package.optional-dependencies] +cli = [ + { name = "rich" }, + { name = "typer" }, ] [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "responses" }, + { name = "rich" }, { name = "ruff" }, + { name = "typer" }, + { name = "types-requests" }, ] [package.metadata] requires-dist = [ - { name = "pydantic", specifier = ">=2.12.0,<3" }, - { name = "requests", specifier = ">=2.32.0,<3" }, + { name = "pydantic", specifier = ">=2.1.0,<3" }, + { name = "requests", specifier = ">=2.32.4,<3" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" }, + { name = "semver", specifier = ">=3.0.4,<4" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.0,<1" }, ] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=1.15.0,<2" }, { name = "pre-commit", specifier = ">=4.5.0,<5" }, { name = "pytest", specifier = ">=9.0.0,<10" }, { name = "pytest-cov", specifier = ">=7.0.0,<8" }, { name = "responses", specifier = ">=0.26.0,<1" }, + { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", specifier = ">=0.15.0,<0.16" }, + { name = "typer", specifier = ">=0.12.0,<1" }, + { name = "types-requests", specifier = ">=2.32.0" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -311,6 +493,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "platformdirs" version = "4.9.2" @@ -580,6 +771,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruff" version = "0.15.2" @@ -605,6 +809,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -659,6 +881,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"