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)
PYPI_TEA_POSTHOG_API_KEY sbomify's project key PostHog project key for analytics; set it empty to turn analytics off
PYPI_TEA_POSTHOG_HOST https://us.i.posthog.com PostHog ingestion host

Analytics

pypi-tea sends usage events to PostHog so we can see how the service is used. Nobody signs in, so events are anonymous and create no person profiles. Server-side, a client's id is a keyed hash of its IP and User-Agent that changes daily. Neither value is sent; only the client family and version parsed from the User-Agent (libtea, curl, browser, ...) are.

Event Sent by Properties
tea_api_called API endpoint, status_code, versioned_url, duration_ms, client, client_version
sbom_lookup API source endpoint, package, version, os, arch, platform_qualified, has_sbom, wheel_count, sbom_count, sbom_formats, has_invalid_sbom, attested
sbom_lookup_failed API source, reason (invalid_purl, unversioned, unsupported_tei, not_on_pypi, pending, overloaded), package, version
sbom_downloaded API artifact_uuid, package, version, media_type, size_bytes
release_indexed worker releases indexed because someone looked them up: package, version, on_pypi, has_sbom, sbom_count, sbom_formats, duration_ms
$pageview, $pageleave browser tagged with page_type (dashboard, package, package_list, not_found) and the page's package, version, sbom_formats or filter_format
sbomify_cta_clicked browser clicks through to sbomify (cta: start_free, learn_more, product, sign_in; placement: where on the site)
sbom_download_clicked, package_clicked, package_list_filtered, package_list_paginated, hero_cta_clicked, code_copied, external_link_clicked, nav_clicked browser from data-ph-* attributes on the clicked element

HTML pages and SBOM downloads are mostly answered by the edge cache, so their server-side events only count cache misses. The browser events cover what visitors do on the site. To tag a new element, give it data-ph-event="name" and a data-ph-<property> attribute for each property.

Error tracking uses Sentry's own variables and applies to the API, the worker and pypi-tea-refresh alike (events carry a component tag):

Variable Default Description
SENTRY_DSN unset Sentry DSN; unset disables error tracking
SENTRY_ENVIRONMENT production Sentry environment name
SENTRY_TRACES_SAMPLE_RATE 0.01 Share of requests and jobs sent as performance traces
SENTRY_SEND_DEFAULT_PII false Attach request headers and client IPs to events

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.1    # 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.1

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.1
File Size Uploaded Provenance Actions
pypi_tea-26.10.1.tar.gz 144.3 kB With provenance Details for pypi_tea-26.10.1.tar.gz

Built distribution (wheel)

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

Total release size: 300.9 kB

Release files / pypi_tea-26.10.1.tar.gz

Download URL pypi_tea-26.10.1.tar.gz
Size 144.3 kB
Tags Source
SHA-256 checksum
How to use checksums
44eeaea7213bc45a6a7f7ca2edbd59441b96946f1d144627900fb532d8d87e23
BLAKE2b-256 checksum
How to use checksums
44fa35b772a08149df60a5059361dbc6ea9b5bb2e2769f0b41df5c3923c269ef
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 Nov 1, 2026.

Transparency log

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

Download URL pypi_tea-26.10.1-py3-none-any.whl
Size 156.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3c1044a3ed313cee7e943900a2bb02d5ad9e541c13dbb0e3de615824d29ec851
BLAKE2b-256 checksum
How to use checksums
d332809ea035c7d0d12a549db3df55aedbb1002714fffdea7eff97480e36b62a
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 Nov 1, 2026.

Transparency log

Security for pypi-tea 26.10.1

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.1.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 Nov 1, 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