Skip to main content

pypi-tea

CI PyPI version Python License sbomified

A Transparency Exchange API (TEA) server that extracts and serves SBOMs from PyPI packages.

Python wheels can include SBOMs in .dist-info/sboms/ (PEP 770). pypi-tea makes these discoverable through the TEA protocol — give it a PURL like pkg:pypi/requests@2.31.0 and it returns any SBOMs found in the package's wheel files.

How it works

  1. A TEA client queries with a TEI or PURL identifier.
  2. If the release is already indexed, the answer comes straight from the local SQLite store.
  3. If not, the API queues it and waits briefly while the worker:
    • fetches the release from the PyPI JSON API
    • reads each wheel's ZIP central directory and .dist-info/sboms/ members with HTTP range requests (a few KB per wheel, not the whole file)
    • detects and validates each SBOM's format, and checks PEP 740 attestations
  4. The result is stored permanently: wheels are immutable, so a wheel is never fetched twice.

Architecture

graph LR
    Client -->|TEI / PURL| API["pypi-tea-serve<br/>(gunicorn + uvicorn workers)"]
    Crawler["Crawlers"] -->|HTML pages, sitemap| API
    API <-->|reads, enqueue| DB[("SQLite (WAL)")]
    Worker["pypi-tea-worker"] <-->|jobs, results| DB
    Worker -->|JSON API, range requests| PyPI

The service is split so that traffic can never turn into upstream work it can't afford:

  • The API process never talks to PyPI. Every page and TEA response is built from indexed SQLite lookups. Crawlers fetching package pages, listings, the sitemap or SBOM downloads cost a millisecond each, however many arrive at once.
  • All upstream I/O happens in the worker, at a fixed concurrency. A burst of lookups for unknown releases makes the queue longer; it does not multiply outbound connections, threads or memory.
  • On-demand lookups are bounded. A lookup for an unknown release waits at most PYPI_TEA_RESOLVE_WAIT_SECONDS. Past that, or once PYPI_TEA_MAX_PENDING_LOOKUPS lookups are queued, the API answers 503 with Retry-After instead of piling up.
  • Every request has a deadline (PYPI_TEA_REQUEST_TIMEOUT_SECONDS), and each API process caps open connections (PYPI_TEA_MAX_CONNECTIONS_PER_WORKER). Over either limit it sheds load with a fast 503 rather than stalling.
  • Responses are edge-cacheable, with stale-if-error, so Cloudflare can keep serving pages through an origin restart.

Storage

A single SQLite database in WAL mode, so any number of API processes read while the worker writes.

Table Contents
releases One row per resolved package@version: upload date, trimmed PyPI metadata, whether any wheel has an SBOM
wheels Every wheel of a release: digests, size, attestation result, and an error if it couldn't be read
sboms Every SBOM found: format, validity, media type, SHA-256, and the content (zlib-compressed)
entities Deterministic TEA UUID → product / release / component / artifact
listing Materialised browse order for /packages and the sitemap, rebuilt by the worker. Any page is a primary-key range read
jobs Work queue: interactive lookups (priority 0) ahead of backfill (5) and scheduled refresh (9). Claims are leases, so a crashed worker's job is picked up again
missing Releases PyPI says don't exist, retried after an hour
usage_* Query counters for the dashboard, buffered in memory and flushed every 10 s

Nothing expires. The worker re-checks every known project for new releases once a week; re-resolving a known release costs one PyPI JSON request, because already-inspected wheels are reused.

Statistics

GET / is a live dashboard; GET /stats, /stats/timeseries, /stats/usage and /stats/invalid-sboms return the raw JSON. They are computed from the tables above and cached for 60 s per process.

Quick start

Prerequisites

  • Python 3.14+
  • uv

Install and run

git clone https://github.com/sbomify/pypi-tea.git
cd pypi-tea
uv sync
# API and worker in one process, for development:
PYPI_TEA_EMBEDDED_WORKER=true uv run uvicorn pypi_tea.app:app

The server starts at http://localhost:8000 and writes pypi-tea.db in the working directory.

In production, run the two halves separately:

pypi-tea-serve      # API: one worker process per CPU
pypi-tea-worker     # ingest worker

Configuration

All settings are configurable via environment variables with the PYPI_TEA_ prefix:

Variable Default Description
PYPI_TEA_DATABASE_PATH pypi-tea.db SQLite database file (shared by API and worker)
PYPI_TEA_SERVER_ROOT_URL http://localhost:8000 Public root URL (used in TEA discovery responses, canonical links and the sitemap)
PYPI_TEA_PYPI_BASE_URL https://pypi.org PyPI API base URL
PYPI_TEA_TEA_SPEC_VERSION 0.3.0-beta.2 TEA spec version to advertise
PYPI_TEA_ATTESTATION_VERIFICATION true Verify PEP 740 attestations
PYPI_TEA_BIND 127.0.0.1:8000 pypi-tea-serve listen address
PYPI_TEA_WEB_WORKERS 0 (one per CPU) pypi-tea-serve process count
PYPI_TEA_MAX_CONNECTIONS_PER_WORKER 512 Open connections per API process before new ones get 503
PYPI_TEA_FORWARDED_ALLOW_IPS 127.0.0.1 Proxies trusted for X-Forwarded-*
PYPI_TEA_RESOLVE_WAIT_SECONDS 20 How long a lookup for an unknown release waits for the worker
PYPI_TEA_MAX_PENDING_LOOKUPS 500 Queued on-demand lookups before new ones are shed
PYPI_TEA_REQUEST_TIMEOUT_SECONDS 30 Hard per-request deadline
PYPI_TEA_WORKER_CONCURRENCY 16 Releases the worker resolves at once
PYPI_TEA_WHEEL_CONCURRENCY 4 Wheels inspected at once within one release
PYPI_TEA_EMBEDDED_WORKER false Run the worker inside the API process (development/tests only)

Queue maintenance

pypi-tea-refresh --status            # queue depth per priority
pypi-tea-refresh                     # check every known project for a new release now
pypi-tea-refresh --existing          # ...and re-resolve every known release

Migrating from 0.x (Redis)

Since 26.10.0 (the switch to CalVer), pypi-tea stores everything in SQLite and no longer uses Redis. To carry over the set of known releases, queue them from the old keyspace; the worker re-resolves them, releases with SBOMs first:

uvx --from 'pypi-tea[import-redis]' pypi-tea-refresh --import-redis redis://localhost:6379/0

While a release is still queued, its package page answers 503 with Retry-After, so search engines retry rather than drop it. TEA UUIDs are deterministic and unchanged, except for projects with a . in their name: names are now normalised as PyPI does (PEP 503), so zope.interface and zope-interface are one product, zope-interface, and old /package/zope.interface/… URLs redirect there.

TEA endpoints

Endpoint Description
GET /discovery?tei=... TEI-based discovery
GET /products List or search products (idType, idValue query params)
GET /product/{uuid} Get a product
GET /product/{uuid}/releases List releases for a product
GET /productReleases List or search product releases
GET /productRelease/{uuid} Get a product release
GET /productRelease/{uuid}/collection/latest Latest SBOM collection
GET /productRelease/{uuid}/collections All collections
GET /productRelease/{uuid}/collection/{version} Collection by version
GET /component/{uuid} Get a component (wheel)
GET /component/{uuid}/releases Component releases
GET /componentRelease/{uuid} Get a component release with collection
GET /componentRelease/{uuid}/collection/latest Latest collection
GET /componentRelease/{uuid}/collections All collections
GET /componentRelease/{uuid}/collection/{version} Collection by version
GET /artifact/{uuid} Get an artifact (SBOM)

Non-TEA endpoints

Endpoint Description
GET / Statistics dashboard (Tailwind + Chart.js)
GET /package/{name}/{version} SBOM page for a release
GET /packages Browse releases with SBOMs (page, format query params)
GET /sitemap.xml Sitemap index; shards at /sitemap-{n}.xml
GET /attestation/{package}/{version} PEP 740 attestation results per wheel
GET /stats Raw statistics JSON
GET /stats/timeseries Daily statistics (30 days)
GET /healthz Liveness: 200 when the database is readable

Example

Discover SBOMs for a package:

curl "http://localhost:8000/discovery?tei=urn:tei:purl:localhost:pkg:pypi/cyclonedx-python-lib@8.4.0"

Search by PURL:

curl "http://localhost:8000/products?idType=PURL&idValue=pkg:pypi/cyclonedx-python-lib@8.4.0"

Data model

PyPI Concept TEA Entity UUID derived from
Package (e.g. requests) Product uuid5(NS, "pkg:pypi/requests")
Version (e.g. 2.31.0) ProductRelease uuid5(NS, "pkg:pypi/requests@2.31.0")
Wheel file Component + ComponentRelease uuid5(NS, "wheel:<filename>") / uuid5(NS, <wheel_url>)
SBOM file in wheel Artifact uuid5(NS, "sbom:<wheel_url>:<sbom_path>")

All UUIDs are deterministic (UUID v5 with a fixed namespace) so they're stable across requests and server restarts.

Deployment

A systemd setup is included in deploy/:

sudo ./deploy/setup.sh 26.10.0    # installs into /opt/pypi-tea, enables both services
sudoedit /etc/default/pypi-tea   # PYPI_TEA_SERVER_ROOT_URL=https://tea.example.com
sudo systemctl restart pypi-tea pypi-tea-worker

The services run from a fixed virtualenv, so restarts don't depend on PyPI being reachable. The database lives in /var/lib/pypi-tea/.

Logs:

journalctl -u pypi-tea -f
journalctl -u pypi-tea-worker -f

Development

# Install dev dependencies
uv sync --group dev

# Run tests (no services needed; the conformance suite reaches live PyPI)
uv run pytest

# Lint
uv run ruff check src/ tests/

# Format
uv run ruff format src/ tests/

# Type check
uv run mypy src/

Conformance

The test suite runs libtea's conformance checks against an in-process server. 21 of 26 checks pass; 5 CLE (Collection Lifecycle Event) checks are skipped as CLE is not implemented.

Versioning

Versions are CalVer, YY.M.PATCH, matching the other sbomify projects: the two-digit year and month of the release, then a counter that restarts at 0 each month. Releases are tagged vYY.M.PATCH (for example v26.10.0). 26.10.0 follows 0.6.2, so version ordering still holds for pip and uv.

License

Apache-2.0

Metadata

Release files for pypi-tea 26.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pypi-tea 26.10.0
File Size Uploaded Provenance Actions
pypi_tea-26.10.0.tar.gz 105.0 kB With provenance Details for pypi_tea-26.10.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pypi-tea 26.10.0
File Interpreter ABI Platform Actions
pypi_tea-26.10.0-py3-none-any.whl Python 3 none any Details for pypi_tea-26.10.0-py3-none-any.whl

Total release size: 222.1 kB

Release files / pypi_tea-26.10.0.tar.gz

Download URL pypi_tea-26.10.0.tar.gz
Size 105.0 kB
Tags Source
SHA-256 checksum
How to use checksums
fe1bf7e3375af1e499d21fc3d2c4ce0308cde574c2859e9725f0e4ab09871022
BLAKE2b-256 checksum
How to use checksums
1e7dd08427dc7a7c71ac19f1f0e2d53c17d9f483d46ac7f9b7c197a7bc2d88f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

Release files / pypi_tea-26.10.0-py3-none-any.whl

Download URL pypi_tea-26.10.0-py3-none-any.whl
Size 117.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
45cc9a22e2ea2ed98de95841a13986e87ebaffba7fde864e8b1078081d07c420
BLAKE2b-256 checksum
How to use checksums
240b3a53ffc6f90573270a835d9e36f2e85ce44ded1a7fc1266496848d573d08
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

Security for pypi-tea 26.10.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for pypi_tea-26.10.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page