diff --git a/pyproject.toml b/pyproject.toml index a48e2d16..7a4bbd99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "conan>=2.0,<3", "questionary>=2.0.1,<3", "pipdeptree>=2.0.0", + "libtea>=0.4.0,<1", ] [project.urls] @@ -106,7 +107,6 @@ exclude = [ "docs/", ] - [tool.ruff] line-length = 120 target-version = "py310" diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index ea8c802b..f9fe3893 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -32,6 +32,7 @@ PURLSource, PyPISource, RepologySource, + TeaSource, ) @@ -57,6 +58,7 @@ def create_default_registry() -> SourceRegistry: Tier 2 - Primary Aggregators (40-49): - DepsDevSource (40) - Google Open Source Insights + - TeaSource (43) - TEA server (auto-discovers from PURL type, TEA_BASE_URL overrides) - EcosystemsSource (45) - ecosyste.ms multi-ecosystem aggregator Tier 3 - Fallback Sources (70-99): @@ -64,9 +66,12 @@ def create_default_registry() -> SourceRegistry: - ClearlyDefinedSource (75) - license and attribution data - RepologySource (90) - cross-distro metadata (rate-limited) - Sources are queried sequentially in priority order. If a source returns - all required NTIA fields (description, licenses, supplier), subsequent - sources are skipped. + Sources are queried sequentially in priority order with two-phase early exit: + 1. If NTIA fields (description, licenses, supplier) AND all CLE fields + (release_date, eos, eol) are filled, remaining sources are skipped. + 2. If only NTIA fields are filled but CLE is missing, non-CLE sources + are skipped while lifecycle-capable sources (provides_cle=True) + continue to run. Returns: Configured SourceRegistry @@ -80,6 +85,7 @@ def create_default_registry() -> SourceRegistry: registry.register(ConanSource()) registry.register(DebianSource()) registry.register(DepsDevSource()) + registry.register(TeaSource()) registry.register(EcosystemsSource()) registry.register(PURLSource()) registry.register(ClearlyDefinedSource()) @@ -271,6 +277,7 @@ def clear_all_caches() -> None: from .sources.pubdev import clear_cache as clear_pubdev from .sources.pypi import clear_cache as clear_pypi from .sources.repology import clear_cache as clear_repology + from .sources.tea import clear_cache as clear_tea clear_license_db() clear_lifecycle() @@ -280,6 +287,7 @@ def clear_all_caches() -> None: clear_conan() clear_debian() clear_depsdev() + clear_tea() clear_ecosystems() clear_clearlydefined() clear_repology() diff --git a/sbomify_action/_enrichment/metadata.py b/sbomify_action/_enrichment/metadata.py index 2891d3bd..fac35a82 100644 --- a/sbomify_action/_enrichment/metadata.py +++ b/sbomify_action/_enrichment/metadata.py @@ -129,4 +129,5 @@ def has_data(self) -> bool: or self.maintainer_name or self.cle_eos or self.cle_eol + or self.cle_release_date ) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 59502c1b..80ec7761 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -1,6 +1,6 @@ """Source registry for managing data source plugins.""" -from typing import Any, Dict, List, Optional +from typing import Any import requests from packageurl import PackageURL @@ -33,7 +33,7 @@ class SourceRegistry: def __init__(self) -> None: """Initialize an empty registry.""" - self._sources: List[DataSource] = [] + self._sources: list[DataSource] = [] def register(self, source: DataSource) -> None: """ @@ -47,7 +47,7 @@ def register(self, source: DataSource) -> None: self._sources.append(source) logger.debug(f"Registered data source: {source.name} (priority={source.priority})") - def get_sources_for(self, purl: PackageURL) -> List[DataSource]: + def get_sources_for(self, purl: PackageURL) -> list[DataSource]: """ Get all applicable sources for a PURL, sorted by priority. @@ -66,13 +66,16 @@ def fetch_metadata( purl: PackageURL, session: requests.Session, merge_results: bool = True, - ) -> Optional[NormalizedMetadata]: + ) -> NormalizedMetadata | None: """ Fetch metadata using the priority chain of sources. - Tries sources in priority order, stopping early when we have - sufficient data (description, licenses, supplier). Only continues - to lower-priority sources if critical fields are missing. + Tries sources in priority order with two-phase early exit: + 1. If NTIA fields (description, licenses, supplier) AND all CLE + fields (release_date, eos, eol) are filled, stop entirely. + 2. If only NTIA fields are filled but CLE is missing, skip + non-CLE sources and continue only with lifecycle-capable + sources (those declaring ``provides_cle = True``). Args: purl: Parsed PackageURL object @@ -87,13 +90,22 @@ def fetch_metadata( logger.debug(f"No sources available for PURL type: {purl.type}") return None - result: Optional[NormalizedMetadata] = None + result: NormalizedMetadata | None = None for source in sources: - # Stop early if we already have all core NTIA fields + # Two-phase early exit: (1) if NTIA + all CLE fields are filled, stop entirely; + # (2) if only NTIA is filled but CLE is missing, skip non-CLE sources and + # continue only with lifecycle-capable sources (those declaring provides_cle=True). if result and result.description and result.licenses and result.supplier: - logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") - break + if result.cle_release_date and result.cle_eos and result.cle_eol: + logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") + break + # NTIA complete but CLE missing — only continue with lifecycle-capable sources. + # Sources opt in by declaring `provides_cle = True` (not in the Protocol; + # checked via getattr so non-CLE sources need no changes). + if not getattr(source, "provides_cle", False): + logger.debug(f"Skipping {source.name} - NTIA complete, not a CLE provider for {purl.name}") + continue try: metadata = source.fetch(purl, session) @@ -114,7 +126,7 @@ def fetch_metadata( return result - def list_sources(self) -> List[Dict[str, Any]]: + def list_sources(self) -> list[dict[str, Any]]: """ List all registered sources with their priorities. diff --git a/sbomify_action/_enrichment/sources/__init__.py b/sbomify_action/_enrichment/sources/__init__.py index 5f2a1680..b1564541 100644 --- a/sbomify_action/_enrichment/sources/__init__.py +++ b/sbomify_action/_enrichment/sources/__init__.py @@ -12,6 +12,7 @@ from .purl import PURLSource from .pypi import PyPISource from .repology import RepologySource +from .tea import TeaSource __all__ = [ "ClearlyDefinedSource", @@ -26,4 +27,5 @@ "PURLSource", "PyPISource", "RepologySource", + "TeaSource", ] diff --git a/sbomify_action/_enrichment/sources/lifecycle.py b/sbomify_action/_enrichment/sources/lifecycle.py index 174cda3d..66f9ec5b 100644 --- a/sbomify_action/_enrichment/sources/lifecycle.py +++ b/sbomify_action/_enrichment/sources/lifecycle.py @@ -63,6 +63,10 @@ class LifecycleSource: def name(self) -> str: return "sbomify-lifecycle-db" + @property + def provides_cle(self) -> bool: + return True + @property def priority(self) -> int: # Priority 5: Very high - local data with no API calls diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py new file mode 100644 index 00000000..4e76a523 --- /dev/null +++ b/sbomify_action/_enrichment/sources/tea.py @@ -0,0 +1,281 @@ +"""TEA (Transparency Exchange API) enrichment source. + +Queries TEA servers for product metadata and CLE (Common Lifecycle Enumeration) +data using TEI auto-discovery from PURL type. + +Each PURL type maps to a known TEA domain (e.g. ``pypi`` → ``pypi.sbomify.com``). +The source discovers the TEA server via ``.well-known/tea`` and fetches CLE +lifecycle data. + +``TEA_BASE_URL`` env var overrides auto-discovery for all PURL types. + +Provides: +- Release date, end-of-support, end-of-life from CLE events +- License information from CLE ``released`` events + +Priority 43 (Tier 2 aggregator). +""" + +import hashlib +import ipaddress +import os +from urllib.parse import urlparse + +import requests +from libtea import TeaClient +from libtea.exceptions import TeaError, TeaNotFoundError +from libtea.models import CLEEventType +from packageurl import PackageURL + +from sbomify_action.logging_config import logger + +from ..metadata import NormalizedMetadata + +# PURL type → TEA domain for .well-known/tea discovery. +# Expand as sbomify indexes more ecosystems. +PURL_TYPE_TO_TEA_DOMAIN: dict[str, str] = { + "pypi": "pypi.sbomify.com", +} + +_cache: dict[str, NormalizedMetadata | None] = {} +_client_cache: dict[str, TeaClient] = {} +_discovery_failures: dict[str, int] = {} +_url_safety_cache: dict[str, bool] = {} + +_MAX_DISCOVERY_ATTEMPTS = 2 + +DEFAULT_TIMEOUT = 15 + + +_BLOCKED_HOSTNAMES = frozenset({"localhost", "metadata.google.internal", "kubernetes.default.svc"}) + + +def _is_safe_url(url: str) -> bool: + """Check that a URL does not point to private/internal addresses (cached). + + Validates scheme, blocked hostnames, IP literals, and DNS-resolved addresses. + Results are cached per URL to avoid repeated DNS resolution. + """ + if url in _url_safety_cache: + return _url_safety_cache[url] + result = _check_url_safety(url) + _url_safety_cache[url] = result + return result + + +def _check_url_safety(url: str) -> bool: + """Perform the actual URL safety check (uncached).""" + try: + parsed = urlparse(url) + if (parsed.scheme or "").lower() not in ("http", "https"): + return False + hostname = parsed.hostname + if not hostname: + return False + if hostname.lower() in _BLOCKED_HOSTNAMES: + return False + # Check IP literals directly + try: + ip = ipaddress.ip_address(hostname) + return _is_public_ip(ip) + except ValueError: + pass # Not an IP literal — resolve via DNS below + # Resolve hostname and check all resulting addresses + import socket + + try: + addrinfo = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP) + for _family, _type, _proto, _canonname, sockaddr in addrinfo: + ip = ipaddress.ip_address(sockaddr[0]) + if not _is_public_ip(ip): + return False + except socket.gaierror: + return False # Unresolvable hostname is not safe + return True + except Exception: + return False + + +def _is_public_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True if the IP address is globally routable unicast.""" + return ip.is_global + + +def _redact_url(url: str) -> str: + """Return scheme://hostname:port only, stripping credentials, path, and query.""" + try: + parsed = urlparse(url) + hostname = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + scheme = f"{parsed.scheme}://" if parsed.scheme else "" + return f"{scheme}{hostname}{port}" if hostname else "" + except Exception: + return "" + + +def clear_cache() -> None: + """Clear the module-level caches (closes cached clients).""" + for client in _client_cache.values(): + if hasattr(client, "close"): + try: + client.close() + except Exception: + pass + _cache.clear() + _client_cache.clear() + _discovery_failures.clear() + _url_safety_cache.clear() + + +def _get_client(purl_type: str) -> TeaClient | None: + """Get or create a cached TeaClient for the given PURL type.""" + token = os.getenv("TEA_TOKEN") + base_url_override = os.getenv("TEA_BASE_URL") + + if base_url_override: + url_hash = hashlib.sha256(base_url_override.encode()).hexdigest()[:16] + token_hash = hashlib.sha256((token or "").encode()).hexdigest()[:16] + cache_key = f"base_url:{url_hash}:token:{token_hash}" + if cache_key not in _client_cache: + if not _is_safe_url(base_url_override): + logger.warning(f"TEA_BASE_URL rejected (private/internal address): {_redact_url(base_url_override)}") + return None + _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) + return _client_cache[cache_key] + + domain = PURL_TYPE_TO_TEA_DOMAIN.get(purl_type) + if not domain: + return None + + cache_key = f"domain:{domain}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" + if cache_key not in _client_cache: + if _discovery_failures.get(cache_key, 0) >= _MAX_DISCOVERY_ATTEMPTS: + return None + try: + _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + except Exception as exc: + _discovery_failures[cache_key] = _discovery_failures.get(cache_key, 0) + 1 + logger.warning( + f"TEA well-known discovery failed for {domain} " + f"(attempt {_discovery_failures[cache_key]}/{_MAX_DISCOVERY_ATTEMPTS}): {exc}" + ) + return None + return _client_cache[cache_key] + + +def _purl_to_search_value(purl: PackageURL) -> str: + """Convert a PackageURL to a canonical string (no qualifiers/subpath).""" + # str() needed: packageurl lacks type stubs, to_string() returns Any + return str(PackageURL(type=purl.type, namespace=purl.namespace, name=purl.name, version=purl.version).to_string()) + + +class TeaSource: + """Enrichment source that discovers TEA servers and fetches CLE data. + + Note: The ``session`` parameter in ``fetch()`` is accepted for protocol + compliance but unused. libtea manages its own HTTP transport with separate + SSRF protections, authentication, and retry logic. TEA requests will not + carry the shared session's User-Agent or proxy configuration. + """ + + @property + def name(self) -> str: + return "tea" + + @property + def priority(self) -> int: + return 43 + + @property + def provides_cle(self) -> bool: + return True + + def supports(self, purl: PackageURL) -> bool: + """Supported when PURL type has a known TEA domain or a safe TEA_BASE_URL is set.""" + base_url = os.getenv("TEA_BASE_URL") + if base_url: + # If an override is present but unsafe, treat TEA as unsupported + # to avoid wasted calls and log spam. + return _is_safe_url(str(base_url)) + return purl.type in PURL_TYPE_TO_TEA_DOMAIN + + def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: + """Discover TEA server from PURL type and fetch metadata.""" + purl_str = _purl_to_search_value(purl) + token = os.getenv("TEA_TOKEN") or "" + base_url = os.getenv("TEA_BASE_URL") or "" + env_hash = hashlib.sha256(f"{base_url}:{token}".encode()).hexdigest()[:16] + cache_key = f"tea:{purl_str}:{env_hash}" + + if cache_key in _cache: + logger.debug(f"Cache hit (tea): {purl_str}") + return _cache[cache_key] + + try: + metadata = self._fetch_from_tea(purl, purl_str) + _cache[cache_key] = metadata + return metadata + except Exception as exc: + logger.warning(f"TEA enrichment failed for {purl_str}: {exc}") + _cache[cache_key] = None + return None + + def _fetch_from_tea(self, purl: PackageURL, purl_str: str) -> NormalizedMetadata | None: + """Discover server and fetch metadata.""" + client = _get_client(purl.type) + if not client: + return None + + # Search for product releases matching this PURL + response = client.search_product_releases(id_type="PURL", id_value=purl_str, page_size=1) + if not response.results: + logger.debug(f"No TEA product releases found for: {purl_str}") + return None + + release = response.results[0] + logger.debug(f"Found TEA product release: {release.product_name} {release.version} ({release.uuid})") + + field_sources: dict[str, str] = {} + + # Extract release date + cle_release_date: str | None = None + if release.release_date: + cle_release_date = release.release_date.isoformat() + field_sources["cle_release_date"] = self.name + + # Fetch CLE lifecycle data + cle_eos: str | None = None + cle_eol: str | None = None + license_expr: str | None = None + try: + cle = client.get_product_release_cle(release.uuid) + for event in cle.events: + if event.type == CLEEventType.END_OF_SUPPORT and not cle_eos: + cle_eos = event.effective.isoformat() + field_sources["cle_eos"] = self.name + elif event.type == CLEEventType.END_OF_LIFE and not cle_eol: + cle_eol = event.effective.isoformat() + field_sources["cle_eol"] = self.name + elif event.type == CLEEventType.RELEASED and event.license and not license_expr: + license_expr = event.license + field_sources["licenses"] = self.name + except TeaNotFoundError: + logger.debug(f"No CLE data for release {release.uuid}") + except TeaError as exc: + logger.debug(f"CLE lookup failed for {release.uuid}: {exc}") + + licenses = [license_expr] if license_expr else [] + + metadata = NormalizedMetadata( + licenses=licenses, + cle_release_date=cle_release_date, + cle_eos=cle_eos, + cle_eol=cle_eol, + source=self.name, + field_sources=field_sources, + ) + + if metadata.has_data(): + logger.debug(f"Successfully enriched from TEA: {purl_str}") + return metadata + return None diff --git a/sbomify_action/cli/main.py b/sbomify_action/cli/main.py index 8a8be492..9934ba7d 100644 --- a/sbomify_action/cli/main.py +++ b/sbomify_action/cli/main.py @@ -56,6 +56,7 @@ ) from ..spdx3 import is_spdx3 from ..upload import upload_sbom +from .tea import tea_group # Import version for tool metadata with multiple fallback mechanisms @@ -312,6 +313,7 @@ def validate(self) -> None: if not isinstance(product_releases_list, list): raise ConfigurationError('PRODUCT_RELEASE must be a JSON list like ["product_id:v1.2.3"]') + validated_releases = [] for release in product_releases_list: if not isinstance(release, str) or ":" not in release: raise ConfigurationError( @@ -319,18 +321,23 @@ def validate(self) -> None: ) product_id, version = release.split(":", 1) + product_id = product_id.strip() + version = version.strip() + # Validate that product_id looks like a proper ID (not empty) - if not product_id.strip(): + if not product_id: raise ConfigurationError( f"Invalid product_id in PRODUCT_RELEASE: '{release}'. Product ID cannot be empty." ) - if not version.strip(): + if not version: raise ConfigurationError( f"Invalid version in PRODUCT_RELEASE: '{release}'. Version cannot be empty." ) - # Store the parsed list back for later use - self.product_releases = product_releases_list + validated_releases.append(f"{product_id}:{version}") + + # Store the parsed and cleaned list back for later use + self.product_releases = validated_releases logger.info(f"Validated product releases: {self.product_releases}") except json.JSONDecodeError as e: @@ -2341,6 +2348,9 @@ def init_cmd(output: str) -> None: sys.exit(run_wizard(output)) +cli.add_command(tea_group, "tea") + + def main() -> None: """Main entry point for the sbomify action. diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py new file mode 100644 index 00000000..4698435c --- /dev/null +++ b/sbomify_action/cli/tea.py @@ -0,0 +1,178 @@ +"""TEA (Transparency Exchange API) CLI subcommand group. + +Re-exports libtea's CLI as ``sbomify-action tea``, plus a custom ``fetch`` +command that combines discovery + collection lookup + artifact download. +""" + +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import click +import libtea.cli as _libtea_cli +from libtea.cli import app as tea_group +from libtea.exceptions import TeaError +from libtea.models import ArtifactFormat, ArtifactType + +# Bind private helpers via the module to avoid hard-failing imports if a +# future libtea version removes them. We own libtea and these are stable, +# but this provides a clear error message if the API changes. +try: + _build_client = _libtea_cli._build_client # type: ignore[attr-defined] + _error = _libtea_cli._error # type: ignore[attr-defined] +except AttributeError as _exc: + _compat_err = str(_exc) + + def _error(message: str) -> None: # type: ignore[misc] + raise SystemExit(f"Incompatible libtea version: {_compat_err}. {message}") + + def _build_client(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] + _error("Required helper _build_client is missing from libtea.cli.") + + +__all__ = ["tea_group"] + +_BOM_MEDIA_TYPES = ( + "application/vnd.cyclonedx+json", + "application/spdx+json", + "application/json", +) + + +def _select_best_format( + formats: Sequence[ArtifactFormat], + preferred_media_types: tuple[str, ...] = _BOM_MEDIA_TYPES, +) -> ArtifactFormat | None: + """Select the best artifact format by media type preference.""" + for preferred in preferred_media_types: + for fmt in formats: + if fmt.media_type and fmt.media_type == preferred and fmt.url: + return fmt + for fmt in formats: + if fmt.url: + return fmt + return None + + +@tea_group.command() # type: ignore[untyped-decorator] +@click.option("--tei", default=None, help="TEI URN to discover and fetch SBOM for") +@click.option("--product-release-uuid", default=None, help="Product release UUID to fetch from") +@click.option("--component-release-uuid", default=None, help="Component release UUID to fetch from") +@click.option( + "--artifact-type", + type=click.Choice([t.value for t in ArtifactType], case_sensitive=False), + default=ArtifactType.BOM.value, + help="Artifact type to download (default: BOM)", +) +@click.option("-o", "--output", "output_path", required=True, type=click.Path(), help="Output file path") +@click.option("--base-url", envvar="TEA_BASE_URL", default=None, help="TEA server base URL") +@click.option("--domain", default=None, help="Domain for .well-known/tea discovery") +@click.option( + "--token", + envvar="TEA_TOKEN", + default=None, + help="Bearer token (prefer TEA_TOKEN env var to avoid shell history exposure)", +) +@click.option( + "--auth", + envvar="TEA_AUTH", + default=None, + help="Basic auth as USER:PASSWORD (prefer TEA_AUTH env var to avoid shell history exposure)", +) +@click.option("--timeout", type=click.FloatRange(min=0.1), default=30.0, help="Request timeout") +@click.option("--use-http", is_flag=True, help="Use HTTP instead of HTTPS") +@click.option("--port", type=int, default=None, help="Port for well-known resolution") +@click.option("--allow-private-ips", is_flag=True, help="Allow private IPs (WARNING: weakens SSRF protections)") +def fetch( + tei: str | None, + product_release_uuid: str | None, + component_release_uuid: str | None, + artifact_type: str, + output_path: str, + base_url: str | None, + domain: str | None, + token: str | None, + auth: str | None, + timeout: float, + use_http: bool, + port: int | None, + allow_private_ips: bool, +) -> None: + """Fetch an SBOM from a TEA server in one step. + + Combines discovery, collection lookup, artifact selection, and download. + Provide --tei for automatic discovery or --product-release-uuid / + --component-release-uuid for direct lookup. + + \b + Examples: + sbomify-action tea fetch --tei "urn:tei:purl:example.com:pkg:pypi/requests@2.31" -o sbom.json + sbomify-action tea fetch --product-release-uuid abc-123 -o sbom.json --base-url https://tea.example.com/v1 + """ + identifiers = sum(1 for x in (tei, product_release_uuid, component_release_uuid) if x) + if identifiers == 0: + _error("Must specify --tei, --product-release-uuid, or --component-release-uuid") + if identifiers > 1: + _error("Only one of --tei, --product-release-uuid, or --component-release-uuid may be specified") + + if allow_private_ips: + print("WARNING: --allow-private-ips weakens SSRF protections for artifact downloads", file=sys.stderr) + + target_type = ArtifactType(artifact_type) + dest = Path(output_path) + + try: + with _build_client( + base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips + ) as client: + pr_uuid = product_release_uuid + cr_uuid = component_release_uuid + + if tei and not pr_uuid and not cr_uuid: + discoveries = client.discover(tei) + if not discoveries: + _error(f"No product releases found for TEI: {tei}") + pr_uuid = discoveries[0].product_release_uuid + print(f"Discovered product release: {pr_uuid}", file=sys.stderr) + + collection = None + if pr_uuid: + collection = client.get_product_release_collection_latest(pr_uuid) + elif cr_uuid: + collection = client.get_component_release_collection_latest(cr_uuid) + else: + _error("Internal error: no UUID resolved") + + assert collection is not None # unreachable: _error() is NoReturn + matching = [a for a in collection.artifacts if a.type == target_type] + if not matching: + available = {a.type.value for a in collection.artifacts if a.type} + _error( + f"No {target_type.value} artifact found. Available types: {', '.join(sorted(available)) or 'none'}" + ) + + artifact = matching[0] + if not artifact.formats: + _error(f"Artifact '{artifact.name}' has no downloadable formats") + + fmt = _select_best_format(artifact.formats) + if not fmt: + _error(f"No downloadable format found for artifact '{artifact.name}'") + return # unreachable: _error is NoReturn, but helps mypy narrow fmt + + if not fmt.url: + _error(f"Selected format for artifact '{artifact.name}' has no download URL") + return # unreachable: _error is NoReturn, but helps mypy narrow fmt.url + + print(f"Downloading {artifact.name} ({fmt.media_type or 'unknown'}) ...", file=sys.stderr) + + result_path = client.download_artifact( + fmt.url, dest, verify_checksums=list(fmt.checksums) if fmt.checksums else None + ) + print(f"Saved to {result_path}", file=sys.stderr) + + except TeaError as exc: + _error(str(exc)) + except OSError as exc: + _error(f"I/O error: {exc}") diff --git a/tests/conftest.py b/tests/conftest.py index bbe53e40..67421b39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """Pytest configuration and shared fixtures for all tests.""" +from unittest.mock import MagicMock, patch + import pytest @@ -13,3 +15,27 @@ def disable_sentry_for_tests(monkeypatch): this by setting TELEMETRY=true in their own fixtures or patches. """ monkeypatch.setenv("TELEMETRY", "false") + + +@pytest.fixture(autouse=True) +def mock_tea_client(request, monkeypatch): + """Prevent TeaSource from making real network calls in non-TEA tests. + + libtea uses its own HTTP transport, so mocking requests.Session does not + intercept TEA network calls. This fixture clears TEA_BASE_URL (so the + direct constructor path is never taken) and patches from_well_known to + return a no-op client. + + Tests in test_tea_*.py are skipped since they provide their own mocks. + """ + if request.module.__name__.startswith("tests.test_tea_") or request.module.__name__.startswith("test_tea_"): + yield + return + from sbomify_action._enrichment.sources.tea import clear_cache as clear_tea_cache + + clear_tea_cache() + monkeypatch.delenv("TEA_BASE_URL", raising=False) + mock_client = MagicMock() + mock_client.search_product_releases.return_value = MagicMock(results=()) + with patch("sbomify_action._enrichment.sources.tea.TeaClient.from_well_known", return_value=mock_client): + yield diff --git a/tests/test_enrichment_module.py b/tests/test_enrichment_module.py index 9d8f7723..11ac0434 100644 --- a/tests/test_enrichment_module.py +++ b/tests/test_enrichment_module.py @@ -708,6 +708,85 @@ def test_sources_sorted_by_priority(self): priorities = [s.priority for s in sources] assert priorities == sorted(priorities) + def test_early_exit_skips_non_cle_sources_after_ntia_complete(self): + """After NTIA fields are filled, non-CLE sources should be skipped.""" + registry = SourceRegistry() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + session = Mock() + + # Source 1: fills all NTIA fields (priority 10) + ntia_source = Mock() + ntia_source.name = "pypi.org" + ntia_source.priority = 10 + ntia_source.supports.return_value = True + ntia_source.fetch.return_value = NormalizedMetadata( + description="HTTP library", licenses=["Apache-2.0"], supplier="Kenneth Reitz", source="pypi.org" + ) + + # Source 2: non-CLE source (priority 40) — should be skipped + non_cle_source = Mock() + non_cle_source.name = "deps.dev" + non_cle_source.priority = 40 + non_cle_source.provides_cle = False + non_cle_source.supports.return_value = True + + # Source 3: CLE provider (priority 43) — should still run + cle_source = Mock() + cle_source.name = "tea" + cle_source.priority = 43 + cle_source.provides_cle = True + cle_source.supports.return_value = True + cle_source.fetch.return_value = NormalizedMetadata( + cle_release_date="2024-01-01", cle_eos="2025-12-31", cle_eol="2026-06-30", source="tea" + ) + + registry.register(ntia_source) + registry.register(non_cle_source) + registry.register(cle_source) + + result = registry.fetch_metadata(purl, session) + + ntia_source.fetch.assert_called_once() + non_cle_source.fetch.assert_not_called() + cle_source.fetch.assert_called_once() + assert result.cle_eos == "2025-12-31" + + def test_early_exit_breaks_when_all_cle_fields_present(self): + """When NTIA + all 3 CLE fields are filled, remaining sources should be skipped entirely.""" + registry = SourceRegistry() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + session = Mock() + + # Source fills everything + full_source = Mock() + full_source.name = "pypi.org" + full_source.priority = 10 + full_source.supports.return_value = True + full_source.fetch.return_value = NormalizedMetadata( + description="HTTP library", + licenses=["Apache-2.0"], + supplier="Kenneth Reitz", + cle_release_date="2024-01-01", + cle_eos="2025-12-31", + cle_eol="2026-06-30", + source="pypi.org", + ) + + # Should never run + extra_source = Mock() + extra_source.name = "tea" + extra_source.priority = 43 + extra_source.supports.return_value = True + + registry.register(full_source) + registry.register(extra_source) + + result = registry.fetch_metadata(purl, session) + + full_source.fetch.assert_called_once() + extra_source.fetch.assert_not_called() + assert result.cle_eol == "2026-06-30" + # ============================================================================= # Test Enricher diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py new file mode 100644 index 00000000..a86b745e --- /dev/null +++ b/tests/test_tea_cli.py @@ -0,0 +1,359 @@ +"""Tests for the TEA CLI subcommand group.""" + +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner +from libtea.exceptions import TeaError +from libtea.models import ( + Artifact, + ArtifactFormat, + ArtifactType, + Checksum, + ChecksumAlgorithm, + Collection, + CollectionBelongsTo, + DiscoveryInfo, + TeaServerInfo, +) + +from sbomify_action.cli.main import cli +from sbomify_action.cli.tea import _select_best_format + + +def _mock_client_context(mock_build_client): + """Set up mock_build_client as a context manager returning a MagicMock client.""" + mock_client = MagicMock() + mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + return mock_client + + +def _make_collection(belongs_to, artifacts): + """Create a Collection with defaults filled in.""" + return Collection( + uuid="col-uuid", + version=1, + date=None, + belongs_to=belongs_to, + update_reason=None, + artifacts=artifacts, + ) + + +def _make_bom_artifact(media_type="application/vnd.cyclonedx+json", url="https://cdn.example.com/sbom.json", **kwargs): + """Create a BOM Artifact with a single format.""" + return Artifact( + uuid="art-uuid", + name="sbom", + type=ArtifactType.BOM, + distribution_types=None, + formats=[ArtifactFormat(media_type=media_type, description=None, url=url, signature_url=None, **kwargs)], + ) + + +def _make_discovery(pr_uuid="pr-uuid-1"): + """Create a DiscoveryInfo.""" + return DiscoveryInfo( + product_release_uuid=pr_uuid, + servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], + ) + + +class TestTeaGroup(unittest.TestCase): + """Test that the tea subcommand group is registered.""" + + def setUp(self): + self.runner = CliRunner() + + def test_tea_help(self): + """tea --help should show the TEA CLI help text.""" + result = self.runner.invoke(cli, ["tea", "--help"]) + assert result.exit_code == 0 + assert "TEA" in result.output or "tea" in result.output.lower() + + def test_tea_discover_help(self): + """tea discover --help should show discover subcommand help.""" + result = self.runner.invoke(cli, ["tea", "discover", "--help"]) + assert result.exit_code == 0 + assert "TEI" in result.output or "tei" in result.output.lower() + + def test_tea_conformance_help(self): + """tea conformance --help should show conformance subcommand help.""" + result = self.runner.invoke(cli, ["tea", "conformance", "--help"]) + assert result.exit_code == 0 + assert "conformance" in result.output.lower() + + def test_tea_search_products_help(self): + """tea search-products --help should be available.""" + result = self.runner.invoke(cli, ["tea", "search-products", "--help"]) + assert result.exit_code == 0 + + def test_tea_inspect_help(self): + """tea inspect --help should be available.""" + result = self.runner.invoke(cli, ["tea", "inspect", "--help"]) + assert result.exit_code == 0 + + def test_tea_download_help(self): + """tea download --help should be available.""" + result = self.runner.invoke(cli, ["tea", "download", "--help"]) + assert result.exit_code == 0 + + +class TestTeaFetch(unittest.TestCase): + """Test the custom fetch convenience command.""" + + def setUp(self): + self.runner = CliRunner() + + def test_fetch_help(self): + """tea fetch --help should show fetch subcommand help.""" + result = self.runner.invoke(cli, ["tea", "fetch", "--help"]) + assert result.exit_code == 0 + assert "fetch" in result.output.lower() or "SBOM" in result.output + + def test_fetch_requires_identifier(self): + """tea fetch should fail with clear error if no identifier given.""" + result = self.runner.invoke( + cli, + ["tea", "fetch", "--base-url", "https://tea.example.com/v1", "-o", "sbom.json"], + ) + assert result.exit_code != 0 + assert "--tei" in result.output or "Must specify" in result.output + + def test_fetch_rejects_multiple_identifiers(self): + """tea fetch should reject multiple identifiers.""" + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "--product-release-uuid", + "some-uuid", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + assert "Only one" in result.output + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_tei(self, mock_build_client): + """tea fetch --tei should discover, find BOM, and download.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] + ) + mock_client.download_artifact.return_value = "/tmp/sbom.json" + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:purl:example.com:pkg:pypi/lib@1.0", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_called_once() + mock_client.download_artifact.assert_called_once() + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_no_bom_artifact(self, mock_build_client): + """tea fetch should error when no BOM artifact found.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + + vex_artifact = Artifact( + uuid="art-uuid", + name="vex", + type=ArtifactType.VULNERABILITIES, + distribution_types=None, + formats=[ + ArtifactFormat( + media_type="application/json", + description=None, + url="https://cdn.example.com/vex.json", + signature_url=None, + ) + ], + ) + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [vex_artifact] + ) + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:purl:example.com:pkg:pypi/lib@1.0", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_component_release_uuid(self, mock_build_client): + """tea fetch --component-release-uuid should fetch without discovery.""" + mock_client = _mock_client_context(mock_build_client) + + checksums = (Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abc123"),) + mock_client.get_component_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.COMPONENT_RELEASE, [_make_bom_artifact(checksums=checksums)] + ) + mock_client.download_artifact.return_value = Path("/tmp/sbom.json") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--component-release-uuid", + "cr-uuid-1", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_not_called() + mock_client.get_component_release_collection_latest.assert_called_once_with("cr-uuid-1") + mock_client.download_artifact.assert_called_once() + # Verify checksums were passed through + call_kwargs = mock_client.download_artifact.call_args + assert call_kwargs.kwargs.get("verify_checksums") == list(checksums) + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_product_release_uuid(self, mock_build_client): + """tea fetch --product-release-uuid should fetch without discovery.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] + ) + mock_client.download_artifact.return_value = Path("/tmp/sbom.json") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--product-release-uuid", + "pr-uuid-1", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_not_called() + mock_client.get_product_release_collection_latest.assert_called_once_with("pr-uuid-1") + mock_client.download_artifact.assert_called_once() + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_tea_error(self, mock_build_client): + """tea fetch should handle TeaError gracefully.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.side_effect = TeaError("Server error") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_io_error(self, mock_build_client): + """tea fetch should handle OSError gracefully.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] + ) + mock_client.download_artifact.side_effect = OSError("Disk full") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + + +class TestSelectBestFormat(unittest.TestCase): + """Test the _select_best_format helper.""" + + def _make_fmt(self, media_type=None, url=None): + return MagicMock(media_type=media_type, url=url) + + def test_prefers_cyclonedx(self): + spdx = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + cdx = self._make_fmt("application/vnd.cyclonedx+json", "https://a.com/cdx.json") + assert _select_best_format([spdx, cdx]) is cdx + + def test_prefers_spdx_over_generic(self): + generic = self._make_fmt("application/json", "https://a.com/generic.json") + spdx = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + assert _select_best_format([generic, spdx]) is spdx + + def test_falls_back_to_url(self): + unknown = self._make_fmt("application/xml", "https://a.com/sbom.xml") + assert _select_best_format([unknown]) is unknown + + def test_skips_format_without_url(self): + no_url = self._make_fmt("application/xml", None) + with_url = self._make_fmt("text/plain", "https://a.com/file") + assert _select_best_format([no_url, with_url]) is with_url + + def test_returns_none_for_empty(self): + assert _select_best_format([]) is None + + def test_skips_preferred_format_without_url(self): + """Preferred media type without URL should fall back to secondary format with URL.""" + cdx_no_url = self._make_fmt("application/vnd.cyclonedx+json", None) + spdx_with_url = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + assert _select_best_format([cdx_no_url, spdx_with_url]) is spdx_with_url + + def test_returns_none_when_no_url(self): + no_url = self._make_fmt("application/xml", None) + assert _select_best_format([no_url]) is None diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py new file mode 100644 index 00000000..8ef62c2d --- /dev/null +++ b/tests/test_tea_enrichment.py @@ -0,0 +1,502 @@ +"""Tests for the TEA enrichment source.""" + +import unittest +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock, patch + +from libtea.exceptions import TeaConnectionError, TeaNotFoundError +from libtea.models import ( + CLE, + CLEEvent, + CLEEventType, + Identifier, + PaginatedProductReleaseResponse, + ProductRelease, +) +from packageurl import PackageURL + +from sbomify_action._enrichment.sources.tea import ( + PURL_TYPE_TO_TEA_DOMAIN, + TeaSource, + _is_safe_url, + _purl_to_search_value, + clear_cache, +) + + +def _fake_public_getaddrinfo(*args: Any, **kwargs: Any) -> list[tuple[int, int, int, str, tuple[str, int]]]: + """Return a fake public IP for DNS resolution in tests.""" + import socket + + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("93.184.216.34", 0))] + + +class TestTeaSourceProperties(unittest.TestCase): + """Test TeaSource name and priority.""" + + def test_name(self): + assert TeaSource().name == "tea" + + def test_priority(self): + assert TeaSource().priority == 43 + + +class TestTeaSourceSupports(unittest.TestCase): + """Test supports based on PURL type mapping and env var override.""" + + def test_supports_mapped_purl_type(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + with patch.dict("os.environ", {}, clear=True): + assert source.supports(purl) is True + + def test_does_not_support_unmapped_type_without_env(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + with patch.dict("os.environ", {}, clear=True): + assert source.supports(purl) is False + + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + def test_supports_any_type_with_base_url_override(self, _mock_dns: Any) -> None: + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + with patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}): + assert source.supports(purl) is True + + def test_does_not_support_unsafe_base_url(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + for unsafe_url in ["http://127.0.0.1/v1", "http://10.0.0.1/v1", "http://localhost/v1"]: + with patch.dict("os.environ", {"TEA_BASE_URL": unsafe_url}, clear=True): + assert source.supports(purl) is False, f"Should reject unsafe URL: {unsafe_url}" + + def test_supports_all_mapped_types(self): + source = TeaSource() + with patch.dict("os.environ", {}, clear=True): + for purl_type in PURL_TYPE_TO_TEA_DOMAIN: + purl = PackageURL(type=purl_type, name="test", version="1.0") + assert source.supports(purl) is True, f"Should support {purl_type}" + + +class TestPurlToSearchValue(unittest.TestCase): + """Test PURL string formatting for TEA search.""" + + def test_simple_purl(self): + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + assert _purl_to_search_value(purl) == "pkg:pypi/requests@2.31.0" + + def test_namespaced_purl(self): + purl = PackageURL.from_string("pkg:maven/org.apache/commons-lang@3.12") + assert _purl_to_search_value(purl) == "pkg:maven/org.apache/commons-lang@3.12" + + def test_purl_without_version(self): + purl = PackageURL.from_string("pkg:pypi/requests") + assert _purl_to_search_value(purl) == "pkg:pypi/requests" + + def test_purl_strips_qualifiers(self): + purl = PackageURL.from_string("pkg:deb/debian/bash@5.1?arch=amd64") + assert _purl_to_search_value(purl) == "pkg:deb/debian/bash@5.1" + + def test_purl_strips_subpath(self): + purl = PackageURL.from_string("pkg:maven/org.apache/commons@1.0#sub/path") + assert _purl_to_search_value(purl) == "pkg:maven/org.apache/commons@1.0" + + +class TestIsSafeUrl(unittest.TestCase): + """Test SSRF validation for TEA_BASE_URL.""" + + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + def test_public_url_allowed(self, _mock_dns: Any) -> None: + assert _is_safe_url("https://tea.example.com/v1") is True + + def test_private_ip_rejected(self): + assert _is_safe_url("http://192.168.1.1/v1") is False + assert _is_safe_url("http://10.0.0.1/v1") is False + assert _is_safe_url("http://172.16.0.1/v1") is False + + def test_loopback_rejected(self): + assert _is_safe_url("http://127.0.0.1/v1") is False + + def test_link_local_rejected(self): + assert _is_safe_url("http://169.254.169.254/latest/meta-data/") is False + + def test_localhost_rejected(self): + assert _is_safe_url("http://localhost/v1") is False + + def test_metadata_hostnames_rejected(self): + assert _is_safe_url("http://metadata.google.internal/v1") is False + assert _is_safe_url("http://kubernetes.default.svc/v1") is False + + def test_unspecified_address_rejected(self): + assert _is_safe_url("http://0.0.0.0/v1") is False + + def test_empty_url_rejected(self): + assert _is_safe_url("") is False + + def test_no_hostname_rejected(self): + assert _is_safe_url("file:///etc/passwd") is False + + def test_dns_resolving_to_private_ip_rejected(self) -> None: + """Hostnames that resolve to private IPs (e.g. nip.io) should be rejected.""" + import socket + + private_addrinfo = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.1", 0))] + with patch("socket.getaddrinfo", return_value=private_addrinfo): + assert _is_safe_url("https://10-0-0-1.nip.io/v1") is False + + def test_non_http_scheme_rejected(self): + assert _is_safe_url("ftp://tea.example.com/v1") is False + assert _is_safe_url("gopher://tea.example.com/v1") is False + assert _is_safe_url("javascript://tea.example.com") is False + + +class TestTeaSourceFetch(unittest.TestCase): + """Test fetch behavior with mocked TeaClient.""" + + def setUp(self): + clear_cache() + self.source = TeaSource() + self.session = MagicMock() + self.purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + + def tearDown(self): + clear_cache() + + def _make_release(self, now, has_release_date=True): + """Create a ProductRelease for testing.""" + return ProductRelease( + uuid="pr-uuid", + product="prod-uuid", + product_name="requests", + version="2.31.0", + created_date=now, + release_date=now if has_release_date else None, + identifiers=(Identifier(id_type="PURL", id_value="pkg:pypi/requests@2.31.0"),), + components=(), + ) + + def _make_search_response(self, now, releases=()): + """Create a PaginatedProductReleaseResponse for testing.""" + return PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=len(releases), + results=releases, + ) + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_auto_discovery(self, mock_client_cls): + """Fetch uses from_well_known with domain from PURL type mapping.""" + now = datetime.now(timezone.utc) + release = self._make_release(now) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) + mock_client.get_product_release_cle.return_value = CLE( + events=( + CLEEvent( + id=1, + type=CLEEventType.RELEASED, + effective=now, + published=now, + version="2.31.0", + license="Apache-2.0", + ), + CLEEvent( + id=2, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 12, 31, tzinfo=timezone.utc), + published=now, + ), + CLEEvent( + id=3, + type=CLEEventType.END_OF_LIFE, + effective=datetime(2026, 6, 30, tzinfo=timezone.utc), + published=now, + ), + ), + definitions=None, + ) + + metadata = self.source.fetch(self.purl, self.session) + + # Verify discovery used the mapped domain + mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token=None, timeout=15) + + assert metadata is not None + assert metadata.source == "tea" + assert metadata.supplier is None + assert metadata.licenses == ["Apache-2.0"] + assert metadata.cle_release_date == now.isoformat() + assert metadata.cle_eos == "2025-12-31T00:00:00+00:00" + assert metadata.cle_eol == "2026-06-30T00:00:00+00:00" + assert metadata.field_sources["licenses"] == "tea" + assert metadata.field_sources["cle_eos"] == "tea" + assert metadata.field_sources["cle_eol"] == "tea" + + # Session is intentionally unused (libtea manages its own HTTP transport) + self.session.assert_not_called() + + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}, clear=True) + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_base_url_override(self, mock_client_cls: Any, _mock_dns: Any) -> None: + """TEA_BASE_URL overrides auto-discovery.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + # Use an unmapped type — should still work with TEA_BASE_URL + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + self.source.fetch(purl, self.session) + + # Should use direct URL, not from_well_known + mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token=None, timeout=15) + mock_client_cls.from_well_known.assert_not_called() + + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1", "TEA_TOKEN": "secret"}, clear=True) + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_base_url_with_token(self, mock_client_cls: Any, _mock_dns: Any) -> None: + """TEA_BASE_URL + TEA_TOKEN are passed together.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + self.source.fetch(self.purl, self.session) + mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token="secret", timeout=15) + + @patch.dict("os.environ", {"TEA_BASE_URL": "http://169.254.169.254/latest"}) + def test_fetch_rejects_private_base_url(self): + """TEA_BASE_URL pointing to private IP is rejected.""" + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {"TEA_BASE_URL": "http://localhost:8080/v1"}) + def test_fetch_rejects_localhost_base_url(self): + """TEA_BASE_URL pointing to localhost is rejected.""" + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_no_cle(self, mock_client_cls): + """Fetch returns basic metadata when CLE is not available.""" + now = datetime.now(timezone.utc) + release = self._make_release(now) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) + mock_client.get_product_release_cle.side_effect = TeaNotFoundError("No CLE") + + metadata = self.source.fetch(self.purl, self.session) + + assert metadata is not None + assert metadata.supplier is None + assert metadata.cle_release_date is not None + assert metadata.cle_eos is None + assert metadata.cle_eol is None + assert metadata.licenses == [] + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_not_found(self, mock_client_cls): + """Fetch returns None when PURL is not on TEA server.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_connection_error(self, mock_client_cls): + """Fetch returns None on connection error.""" + mock_client_cls.from_well_known.side_effect = TeaConnectionError("Connection refused") + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_caches_result(self, mock_client_cls): + """Second fetch for same PURL should use cache.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + self.source.fetch(self.purl, self.session) + self.source.fetch(self.purl, self.session) + + # from_well_known should only be called once (client is cached) + assert mock_client_cls.from_well_known.call_count == 1 + # search should also only be called once (metadata result is cached) + assert mock_client.search_product_releases.call_count == 1 + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_client_reused_across_purls(self, mock_client_cls): + """Same domain reuses the cached TeaClient for different PURLs.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + purl1 = PackageURL.from_string("pkg:pypi/requests@2.31.0") + purl2 = PackageURL.from_string("pkg:pypi/flask@3.0.0") + self.source.fetch(purl1, self.session) + self.source.fetch(purl2, self.session) + + # Client created once, but search called twice (different PURLs) + assert mock_client_cls.from_well_known.call_count == 1 + assert mock_client.search_product_releases.call_count == 2 + + @patch.dict("os.environ", {}, clear=True) + def test_fetch_unmapped_type_returns_none(self): + """Fetch returns None for unmapped PURL type without TEA_BASE_URL.""" + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + metadata = self.source.fetch(purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {"TEA_TOKEN": "secret-token"}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_passes_token(self, mock_client_cls): + """TEA_TOKEN env var is passed to client.""" + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + self.source.fetch(self.purl, self.session) + mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token="secret-token", timeout=15) + + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_token_change_creates_new_client(self, mock_client_cls): + """Different TEA_TOKEN values produce different cache keys and separate clients.""" + now = datetime.now(timezone.utc) + mock_client1 = MagicMock() + mock_client2 = MagicMock() + mock_client_cls.from_well_known.side_effect = [mock_client1, mock_client2] + empty_response = self._make_search_response(now) + mock_client1.search_product_releases.return_value = empty_response + mock_client2.search_product_releases.return_value = empty_response + + with patch.dict("os.environ", {"TEA_TOKEN": "token-1"}, clear=True): + self.source.fetch(self.purl, self.session) + + with patch.dict("os.environ", {"TEA_TOKEN": "token-2"}, clear=True): + self.source.fetch(self.purl, self.session) + + assert mock_client_cls.from_well_known.call_count == 2 + calls = mock_client_cls.from_well_known.call_args_list + assert calls[0].kwargs["token"] == "token-1" + assert calls[1].kwargs["token"] == "token-2" + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_duplicate_cle_events_takes_first(self, mock_client_cls): + """When multiple CLE events of the same type exist, the first one wins.""" + now = datetime.now(timezone.utc) + release = self._make_release(now) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) + mock_client.get_product_release_cle.return_value = CLE( + events=( + CLEEvent( + id=1, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 6, 1, tzinfo=timezone.utc), + published=now, + ), + CLEEvent( + id=2, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 12, 31, tzinfo=timezone.utc), + published=now, + ), + ), + definitions=None, + ) + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is not None + assert metadata.cle_eos == "2025-06-01T00:00:00+00:00" + + +class TestClearCache(unittest.TestCase): + """Test that clear_cache properly cleans up.""" + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_clear_cache_clears_results_and_clients(self, mock_client_cls): + """clear_cache should clear both result cache and client cache.""" + from sbomify_action._enrichment.sources.tea import _cache, _client_cache + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, page_start_index=0, page_size=1, total_results=0, results=() + ) + + source = TeaSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + source.fetch(purl, MagicMock()) + + assert len(_cache) > 0 + assert len(_client_cache) > 0 + + clear_cache() + + assert len(_cache) == 0 + assert len(_client_cache) == 0 + + def test_clear_cache_closes_clients(self): + """clear_cache should call close() on cached clients.""" + from sbomify_action._enrichment.sources.tea import _client_cache + + mock_client = MagicMock() + _client_cache["test"] = mock_client + + clear_cache() + + mock_client.close.assert_called_once() + + +class TestTeaInRegistry(unittest.TestCase): + """Test that TeaSource is registered in the default registry.""" + + def test_tea_in_default_registry(self): + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + source_names = [s["name"] for s in registry.list_sources()] + assert "tea" in source_names + + def test_tea_priority_before_ecosystems(self): + """TEA should run before ecosyste.ms (no priority collision).""" + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + sources = registry.list_sources() + tea = next(s for s in sources if s["name"] == "tea") + eco = next(s for s in sources if s["name"] == "ecosyste.ms") + assert tea["priority"] < eco["priority"] + + def test_clear_all_caches_includes_tea(self): + """clear_all_caches should not raise (tea cache is included).""" + from sbomify_action._enrichment.enricher import clear_all_caches + + clear_all_caches() diff --git a/uv.lock b/uv.lock index 287adab6..545a5c49 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "arrow" version = "1.4.0" @@ -209,7 +218,7 @@ wheels = [ [[package]] name = "conan" -version = "2.26.1" +version = "2.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, @@ -222,7 +231,7 @@ dependencies = [ { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/8a33f18c9010f78bb9c57772fd0cfd2ee31606017462543524ea6ac97819/conan-2.26.1.tar.gz", hash = "sha256:5692b1d8badd516878354e9e6134f4df65cf119285b86652e9f3eb810e20838f", size = 567974, upload-time = "2026-02-27T13:16:59.81Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/84/1032763b9fae2de2d32213470f829ece7467bb32ad8b19a9dfd9c77be54d/conan-2.26.2.tar.gz", hash = "sha256:28bfbbd276935623f1b304811335acb9d2b8ce3a57aa649e432d10d4f51ce055", size = 567729, upload-time = "2026-03-05T09:31:30.75Z" } [[package]] name = "coverage" @@ -486,11 +495,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] [[package]] @@ -741,6 +750,20 @@ wheels = [ { 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.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/53/47a81863a148ed2218183af00620a90ccc4aec3d9ab46ef832deb584921e/libtea-0.4.0.tar.gz", hash = "sha256:f68d5976b8372dc8bdda416f9414fced7d09f2957fad95da85f257fe88b10656", size = 60452, upload-time = "2026-03-05T09:03:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/62/ac649a211a027a4b64471b0c857dcb1536941a86c3fe0eeedc9ca83df4d1/libtea-0.4.0-py3-none-any.whl", hash = "sha256:1c40ab69644730a1b81dc89a59eeec8b04d4421d97d8c0e0becc323bffdcd521", size = 67712, upload-time = "2026-03-05T09:03:30.538Z" }, +] + [[package]] name = "license-expression" version = "30.4.4" @@ -1194,6 +1217,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1619,6 +1754,7 @@ dependencies = [ { name = "conan" }, { name = "cyclonedx-bom" }, { name = "cyclonedx-python-lib" }, + { name = "libtea" }, { name = "packageurl-python" }, { name = "pipdeptree" }, { name = "questionary" }, @@ -1650,6 +1786,7 @@ requires-dist = [ { name = "conan", specifier = ">=2.0,<3" }, { name = "cyclonedx-bom", specifier = ">=7.2.1,<8" }, { name = "cyclonedx-python-lib", specifier = ">=11.5.0,<12" }, + { name = "libtea", specifier = ">=0.4.0,<1" }, { name = "packageurl-python", specifier = ">=0.17.6" }, { name = "pipdeptree", specifier = ">=2.0.0" }, { name = "questionary", specifier = ">=2.0.1,<3" }, @@ -1697,17 +1834,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, ] +[[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 = "sentry-sdk" -version = "2.53.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -1861,6 +2007,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3"