TEA Server for PyPI Package SBOMs
Project description
pypi-tea
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
- Client queries with a TEI or PURL identifier
- Server resolves the package via the PyPI JSON API
- Wheel files are inspected using HTTP range requests (via
remotezip) to avoid downloading full wheels — only the ZIP central directory (~16KB) is fetched to check for.dist-info/sboms/entries - If range requests aren't supported (some CDN configurations), falls back to downloading the full wheel
- SBOM files are extracted and mapped to TEA entities
- Everything is cached in Redis and served as TEA-compliant responses
Architecture
┌──────────┐ TEI/PURL ┌───────────┐ JSON API ┌─────────┐
│ Client │ ───────────────> │ pypi-tea │ ──────────────> │ PyPI │
└──────────┘ │ (FastAPI) │ └─────────┘
│ │ range request ┌─────────┐
│ │ ──────────────> │ Wheel │
│ │ (or full GET) │ files │
│ │ └─────────┘
│ │
│ │ <──────────────> │ Redis │
└───────────┘ caching └─────────┘
Caching
All data is stored in Redis. Nothing is persisted to disk — Redis is the sole data store.
| Data | Redis key pattern | TTL | Description |
|---|---|---|---|
| PyPI metadata | pypi:{package}:{version} |
1 hour | JSON API response for a package version |
| SBOM content | sbom:{wheel_url} |
24 hours | Extracted SBOM files from a wheel (JSON array) |
| Negative cache | neg:{wheel_url} |
24 hours | Marker for wheels confirmed to have no SBOMs |
| UUID lookup | uuid:{uuid} |
No expiry | Maps a deterministic UUID to entity metadata |
| Entity index | etype:{entity_type} |
No expiry | Redis set of UUIDs per entity type (for listing) |
| Stats (totals) | stats |
No expiry | Redis hash with cumulative hit/miss counters |
| Stats (time series) | stats:ts:{bucket} |
24 hours | Per-5-minute counter buckets for time-series graphs |
Why these TTLs?
- PyPI metadata changes when new versions are released — 1 hour keeps things reasonably fresh
- Wheel contents are immutable once published — 24 hours is conservative; SBOMs won't change
- Negative cache prevents repeatedly downloading wheels that have no SBOMs
- UUID lookups don't expire because they map deterministic UUIDs to stable data
Statistics
The server tracks cache hit/miss ratios and SBOM availability:
- Cache metrics: hits and misses for PyPI metadata, SBOM content, and negative cache lookups
- SBOM availability: how many wheels had SBOMs vs didn't
- Time series: all counters are also bucketed into 5-minute intervals (24h retention) for trend visualization
Visit GET / for a live dashboard with charts, or GET /stats for raw JSON.
Quick start
Prerequisites
- Python 3.14+
- uv
- Redis
Install and run
git clone https://github.com/sbomify/pypi-tea.git
cd pypi-tea
uv sync
uv run uvicorn pypi_tea.app:app
The server starts at http://localhost:8000. Visit the root URL for a live statistics dashboard.
Configuration
All settings are configurable via environment variables with the PYPI_TEA_ prefix:
| Variable | Default | Description |
|---|---|---|
PYPI_TEA_REDIS_URL |
redis://localhost:6379/0 |
Redis connection URL |
PYPI_TEA_PYPI_BASE_URL |
https://pypi.org |
PyPI API base URL |
PYPI_TEA_SERVER_ROOT_URL |
http://localhost:8000 |
Public root URL (used in TEA discovery responses) |
PYPI_TEA_TEA_SPEC_VERSION |
0.3.0-beta.2 |
TEA spec version to advertise |
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 /stats |
Raw statistics JSON |
GET /stats/timeseries |
Time-bucketed statistics (5-min intervals, 24h retention) |
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 service file and setup script are included in deploy/.
# As root:
sudo ./deploy/setup.sh
sudo systemctl start pypi-tea
The setup script creates a dedicated pypi-tea system user and installs the systemd service. The service uses uvx to run pypi-tea directly from PyPI — no cloning or venv management needed.
Important: Set PYPI_TEA_SERVER_ROOT_URL to your public URL — this is used in TEA discovery responses so clients know where to reach your server.
sudo systemctl edit pypi-tea
[Service]
Environment=PYPI_TEA_SERVER_ROOT_URL=https://tea.example.com
Environment=PYPI_TEA_REDIS_URL=redis://my-redis:6379/1
Logs:
journalctl -u pypi-tea -f
Development
# Install dev dependencies
uv sync --group dev
# Run tests (uses fakeredis, no Redis required)
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.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pypi_tea-0.1.2.tar.gz.
File metadata
- Download URL: pypi_tea-0.1.2.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2464113306da6088518cf17c8dbbca3bdaec9fee0e02b8c211386b7f2afd47c5
|
|
| MD5 |
48e505ad39cece777d25afbc727aecf5
|
|
| BLAKE2b-256 |
f69e4e8cec4ab9b94bcb13659ab0d11c59af0302aedae36fa4e88db0c04baabb
|
Provenance
The following attestation bundles were made for pypi_tea-0.1.2.tar.gz:
Publisher:
pypi.yaml on sbomify/pypi-tea
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pypi_tea-0.1.2.tar.gz -
Subject digest:
2464113306da6088518cf17c8dbbca3bdaec9fee0e02b8c211386b7f2afd47c5 - Sigstore transparency entry: 1042605804
- Sigstore integration time:
-
Permalink:
sbomify/pypi-tea@bee3a43c072ee3bb9228d229d2a99849516d13b8 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/sbomify
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@bee3a43c072ee3bb9228d229d2a99849516d13b8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pypi_tea-0.1.2-py3-none-any.whl.
File metadata
- Download URL: pypi_tea-0.1.2-py3-none-any.whl
- Upload date:
- Size: 36.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
92b9ecccff2a4c53db85e8418f64b771204a9c5fed33f60e9957dcd75fc8d2eb
|
|
| MD5 |
98a086d96e5864698938281ba509032f
|
|
| BLAKE2b-256 |
b87ad7ccf2f4704eb2263dd022f6202fc9abc75c134c59d3c20ed3d9138fb0eb
|
Provenance
The following attestation bundles were made for pypi_tea-0.1.2-py3-none-any.whl:
Publisher:
pypi.yaml on sbomify/pypi-tea
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pypi_tea-0.1.2-py3-none-any.whl -
Subject digest:
92b9ecccff2a4c53db85e8418f64b771204a9c5fed33f60e9957dcd75fc8d2eb - Sigstore transparency entry: 1042605806
- Sigstore integration time:
-
Permalink:
sbomify/pypi-tea@bee3a43c072ee3bb9228d229d2a99849516d13b8 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/sbomify
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@bee3a43c072ee3bb9228d229d2a99849516d13b8 -
Trigger Event:
release
-
Statement type: