python-dependency-vetting
Use this when adding a new entry to pyproject.toml dependencies, bumping a major version of an existing dep, reviewing TPRD §10 dependency declarations, or rendering an sdk-dep-vet-devil-python verdic
Files2
View on GitHubSKILL.mdskills/python-dependency-vetting/SKILL.md
GitHubpython-dependency-vetting (v1.1.0)
Rationale
Every Python dependency added to an SDK is a transitive dependency for every consumer of the SDK. License incompatibility, CVEs in deep transitives, abandoned upstreams, typosquatting attacks — these failure modes scale: one bad dep affects every downstream user. The Python pack's vetting discipline forces a per-dep verdict (ACCEPT / CONDITIONAL / REJECT) at design time before the dep ships in pyproject.toml.
This skill is cited by sdk-dep-vet-devil-python (the agent that renders verdicts), sdk-convention-devil-python (C-15 dependency declaration discipline), sdk-security-devil (cross-checks), sdk-packaging-devil-python (P-7 dep placement), and python-sdk-config-pattern (when Config inherits from a vetted base like pydantic).
Activation signals
- Adding a new entry to
pyproject.toml [project] dependencies. - Bumping a major version of an existing dep.
- TPRD §10 declares a dep that needs review.
sdk-dep-vet-devil-pythonis gating H6.- A consumer asks "why does the SDK pull library X?"
- Reviewing whether a "small enough" pure-Python implementation can replace a dep.
License allowlist
Acceptable (ACCEPT, no review needed):
MITApache-2.0BSD-3-Clause/BSD-2-ClauseISC0BSDMPL-2.0Python-2.0(Python Software Foundation License)UnlicenseCC0-1.0
Conditional (requires user OK at H6 — document the obligation):
LGPL-2.1-or-later/LGPL-3.0-or-later— pip is dynamic linking, but document the obligation per LGPL.EUPL-1.2— compatible with most permissive licenses but requires LICENSE distribution.- Dual-licensed where one option is in the allowlist (declare which option you're consuming under).
Reject (REJECT — must drop or substitute):
GPL-2.0-or-later/GPL-3.0-or-later— copyleft incompatible with permissive SDK posture.AGPL-3.0-or-later— network-AGPL kicks in for SDK servers; even more aggressive than GPL.SSPL— non-OSI; effectively proprietary.- Proprietary / commercial / "all rights reserved".
- Missing license metadata (
UNKNOWNinpip show).
License resolution order
pip show <pkg>— reads METADATALicenseandLicense-Expression(PEP 639) fields.- PyPI JSON API:
GET https://pypi.org/pypi/<pkg>/json→info.licenseandinfo.classifiers(search forLicense :: OSI Approved :: ...). - Source repository's
LICENSE/COPYING/LICENCE/LICENSE.txtfile (best-effort if METADATA empty). - PyPI
info.classifiersTrove license classifier.
If sources DISAGREE (METADATA says MIT but classifier says Apache-2.0), CONDITIONAL with a note. The package author should harmonize; the SDK shouldn't accept ambiguous licensing.
Vulnerability scanning
Tier 1: pip-audit (preferred)
pip-audit --requirement <(echo "<pkg>==<ver>") --format=json --vulnerability-service=osv > audit.json
pip-audit queries the PyPI Advisory Database (and falls back to OSV). Severity mapping:
| pip-audit severity | Verdict |
|---|---|
| CRITICAL / HIGH | REJECT |
| MEDIUM | CONDITIONAL (may ACCEPT if patched version cannot be reached due to constraints; document) |
| LOW | ACCEPT (note in report) |
Always include --vulnerability-service=osv as a secondary cross-check; OSV's database is broader.
Tier 2: safety
safety check --full-report --json --file=<(echo "<pkg>==<ver>") > safety.json
safety checks the pyup.io database. It overlaps with pip-audit but occasionally surfaces CVEs the PyUp team curates that haven't landed in OSV yet. Cross-check — any disclosure in safety NOT in pip-audit is at minimum CONDITIONAL.
Tier 3 (optional): osv-scanner
osv-scanner --lockfile=poetry.lock # OR uv.lock OR requirements.lock
osv-scanner traverses the lock file recursively, surfacing transitive vulnerabilities. Run on the lock file (not pyproject.toml) so transitives are included.
Per-dep checks
For every dep proposed in pyproject.toml, run the full check set:
V-1. License (allowlist above)
V-2. Vulnerability scan (pip-audit + safety + optionally osv-scanner)
V-3. Package size
pip download "<pkg>==<ver>" --no-deps --dest /tmp/vet/ --quiet
du -sk /tmp/vet/*.whl # OR *.tar.gz if no wheel
Buckets:
<5 MB— ACCEPT.5–20 MB— CONDITIONAL (justify; e.g., bundled native or large data files).>20 MB— REJECT unless TPRD §10 waives (example: PyTorch, CUDA toolkits — legitimate but rare).
If only sdist (no wheel): downgrade verdict by one tier — sdist requires the consumer to compile, increasing supply-chain attack surface.
V-4. Last-release age
LATEST_UPLOAD=$(curl -s "https://pypi.org/pypi/<pkg>/json" \
| jq -r '.releases | to_entries | sort_by(.value[0].upload_time) | last | .value[0].upload_time')
| Age | Verdict |
|---|---|
<6 months |
ACCEPT (active maintenance) |
6–18 months |
ACCEPT with note (fine for stable libs, suspicious for evolving libs) |
18–36 months |
CONDITIONAL — abandonware risk; check fork signals |
>36 months |
REJECT unless intentionally frozen (RFC implementations) — TPRD §10 must waive |
Cross-check requires_python from PyPI metadata — must include the SDK's Python floor (>=3.12 for the Python pack default).
V-5. Transitive dependency count
pip download "<pkg>==<ver>" --dest /tmp/vet-t/ --quiet
ls /tmp/vet-t/*.whl | wc -l # excludes the dep itself
| Transitives | Verdict |
|---|---|
<10 |
ACCEPT |
10–30 |
CONDITIONAL — document each first-level transitive |
>30 |
REJECT unless TPRD waives (web frameworks like FastAPI legitimately pull 30+) |
The point is awareness — high transitive count IS the supply-chain attack surface. SDKs are sticky upstream of consumers; the consumer can't drop our transitive without dropping us.
V-6. PyPI adoption
# Best-effort via pypistats.org JSON API
curl -s "https://pypistats.org/api/packages/<pkg>/recent?period=month" | jq .
| Downloads/month | Verdict |
|---|---|
>10 k |
healthy |
1 k – 10 k |
ACCEPT with note |
100 – 1 k |
CONDITIONAL low-adoption (SDK becomes the primary stress-tester) |
<100 |
REJECT negligible-adoption unless TPRD waives |
V-7. GitHub maintenance signals
Best-effort lookup via the source URL declared in PyPI metadata:
- Repository archived flag → REJECT.
- Last commit
>12 monthsago → CONDITIONAL. - Open-issue / closed-issue ratio
>0.5→ CONDITIONAL. - No source repo (PyPI-only, "anonymous" package) → CONDITIONAL
source-not-discoverable. - < 100 stars on a package handling crypto / auth / serialization → CONDITIONAL (security-critical code shouldn't have niche maintenance).
V-8. Sigstore attestation (PEP 740)
PEP 740 attestations for PyPI uploads are increasingly common. Verify via:
python -m sigstore verify identity \
--bundle <pkg>-<ver>.whl.sigstore \
--cert-identity "<expected-identity>" \
--cert-oidc-issuer "https://accounts.google.com" \
<pkg>-<ver>.whl
Attestation present + verified → ACCEPT signal (not required for ACCEPT, but absence is a CONDITIONAL note for security-critical deps). Attestation present but verification fails → REJECT.
The Python ecosystem's Sigstore adoption is partial as of 2026; most popular packages (httpx, aiohttp, pydantic) have started shipping attestations.
V-9. Typosquatting check
# Compare against the top-1000 PyPI list (refresh monthly)
LEVENSHTEIN_DIST=$(python -c "
import Levenshtein, sys, json
top = json.load(open('top-1000-pypi.json'))
target = sys.argv[1]
for popular in top:
d = Levenshtein.distance(target, popular)
if 0 < d <= 2:
print(d, popular)
sys.exit(0)
" "<pkg>")
If the dep's name is ≤ 2 Levenshtein distance from a popular package AND first release <90 days ago → REJECT typosquat. Common attacks: requst (vs requests), urlllib3, numppy, python-dateuitl.
V-10. Native-code provenance
If the wheel contains *.so / *.pyd / *.dylib:
- Confirm wheels exist for the platforms the SDK supports (manylinux2014 / manylinux_2_28 + macOS arm64 + macOS x86_64 + Windows AMD64).
- Verify the build CI is the upstream's official one (cibuildwheel / GitHub Actions in the project's repo). Random PyPI-only binaries are CONDITIONAL.
- Pure-Python alternative existing? Note in report; CONDITIONAL recommends pure-Python where viable.
If only sdist ships and the package contains a C extension: REJECT unless build is reproducible from sdist on a clean Python 3.12 env without external system deps.
V-11. Python version compatibility
requires-pythonfloor must be<= 3.12(the SDK's floor).- If the dep declares an upper bound (
<3.13), confirm the SDK's intended max Python is supported. - ABI tags: pure-Python (
py3-none-any) preferred; ABI3-stable (cp312-abi3-...) acceptable; per-minor wheels (cp312-cp312-...) acceptable but require a regenerate cadence.
V-12. Library-API-shape verification (added v1.1.0)
Added in v1.1.0 in response to the motadata-nats-v1 run's B1-B4 root cause: nats-py>=2.7.0,<3 was vetted clean on V-1..V-11 but the kwargs/API surface drifted between 2.7 (TPRD-cited floor) and 2.14 (env-installed actual). The SDK code targeted 2.7-style kwargs (replicas, allow_rollup, compression=...) which fail at runtime against 2.14 (num_replicas, allow_rollup_hdrs, no compression kwarg). License/CVE checks all PASS — but the API SHAPE is not part of those checks.
The check: for each runtime dep with a declared version range, materialize a venv pinning the highest pinnable minor in the range (i.e. the minor a fresh pip install would resolve to). For each class/function the SDK code references via the design's api-stub, verify via reflection that the kwargs the SDK passes are accepted by that class's __init__ / function signature.
# Resolve the actual minor the dep range would install
HIGHEST=$(python -c "
import json, urllib.request, packaging.specifiers as ps, packaging.version as pv
url = 'https://pypi.org/pypi/<pkg>/json'
data = json.loads(urllib.request.urlopen(url).read())
spec = ps.SpecifierSet('<range-from-pyproject.toml>')
versions = sorted([pv.parse(v) for v in data['releases'] if spec.contains(v)], reverse=True)
print(versions[0])
")
# Materialize a scratch venv at that pin
python -m venv /tmp/dep-vet-shape && /tmp/dep-vet-shape/bin/pip install "<pkg>==$HIGHEST"
# For each cited class/function in design/api-stub, reflect:
/tmp/dep-vet-shape/bin/python -c "
import inspect
from <pkg>.<module> import <cited_class>
sig = inspect.signature(<cited_class>.__init__)
print(list(sig.parameters.keys()))
" > /tmp/dep-vet-shape/<cited_class>-sig.txt
Compare the reflected kwarg list against the SDK's call sites in design api-stub.py (or design's algorithms.md referenced kwargs). Any kwarg the SDK passes that does NOT appear in the resolved-minor's signature: surface as CONDITIONAL — kwarg-rename-required with a hint specifying the SDK location and the resolved-minor's actual kwarg name (look for the closest match in the resolved signature).
| Outcome | Verdict | Action |
|---|---|---|
| All SDK call-site kwargs match resolved-minor signature | ACCEPT | nothing further |
| 1+ kwarg missing in resolved-minor; close-match available | CONDITIONAL — kwarg-rename-required | suggest renames in dep-vet report; design lead must update api-stub OR pin TPRD range tighter |
| 1+ kwarg missing; no close-match (kwarg removed entirely) | CONDITIONAL — kwarg-removed-required | impl must drop the kwarg; field can stay on the SDK's own dataclass for forward-compat, just not threaded into the call |
pip install cannot resolve the range |
INCOMPLETE | network or yank issue; never auto-PROMOTE to ACCEPT |
Integration into D-DEP wave: this check is REQUIRED at H6 for any dep whose runtime API surface is referenced from design/api-stub.py. Skip ONLY for tooling-only deps (pytest, ruff, mypy, build) where the SDK's own code never imports the lib.
Cost: ~30 seconds per dep (venv create + pip install + 1-3 reflect calls). Catches an entire class of "library-evolved-since-TPRD-was-authored" bug at design-time, eliminating the impl→test→re-impl loop.
Cross-reference: paired with python-mock-strategy v1.1.0 strict-signature-mocking guidance — the dep-vet skill is the FIRST line of defense (catches at design); the mock-strategy skill is the SECOND line (catches at unit test if dep-vet was skipped).
Aggregate verdict logic
| Per-dep verdicts | Aggregate |
|---|---|
| All ACCEPT | ACCEPT |
| Any CONDITIONAL, no REJECT | CONDITIONAL — H6 surfaces |
| Any REJECT | REJECT — design lead must drop or substitute |
| Any INCOMPLETE (network failure, tool unavailable) | INCOMPLETE — never auto-promote |
Common alternatives — when REJECT, what to substitute
| Rejected | Alternative | Reason |
|---|---|---|
requests (sync only) |
httpx |
async + sync, pure-Python, MIT, healthy maintenance |
urllib3<1.26 (CVE backlog) |
httpx or urllib3>=2.0 |
newer API, fewer CVEs |
pyyaml (CVE-2017-18342 unsafe load) |
ruamel.yaml (safe by default) OR pyyaml>=6.0 with safe_load only |
YAML default constructor history of unsafe-load CVEs |
pickle (RCE on untrusted input) |
json / msgpack / protobuf |
pickle deserializes arbitrary objects |
dateutil (parser tz-handling bugs) |
stdlib datetime with zoneinfo (3.9+) |
stdlib is sufficient for most cases |
aiohttp (heavyweight; many transitives) |
httpx |
smaller transitive footprint for HTTP client use |
| Anything copyleft | search for permissive alternative | license incompatibility |
Don't blindly substitute — confirm the alternative exists, fits the use case, AND passes its own vetting.
TPRD §10 dependency declaration template
## §10. Dependencies
| Dep | Version | Role | License | Justification |
|-----|---------|------|---------|---------------|
| httpx | >=0.27,<1.0 | runtime | BSD-3-Clause | Async HTTP client; replaces `requests` per §3 async requirement |
| pydantic | >=2.5 | runtime | MIT | Config validation; alternative `@dataclass + __post_init__` is in scope but pydantic shines for the JSON-config consumer flow |
| opentelemetry-api | >=1.20 | runtime | Apache-2.0 | OTel wiring; consumer of the API only — no SDK |
| pytest | >=8.0 | dev (test) | MIT | Test runner |
| ruff | >=0.5 | dev (lint) | MIT | Lint + format |
| mypy | >=1.10 | dev (vet) | MIT | Strict type-check |
### Vetting summary
- License: all permissive (MIT / Apache-2.0 / BSD-3-Clause).
- pip-audit + safety: clean (run 2026-04-28).
- Transitives: 27 total (httpx pulls anyio, sniffio, idna, certifi, h11, h2, hpack, hyperframe, ...).
- Adoption: all >1M downloads/month.
- Native code: none (pydantic 2.x has Rust core but ships pre-built wheels).
- Sigstore: pydantic, httpx, opentelemetry-api ship attestations; pytest does not yet.
BAD anti-patterns
# 1. No bound declared
[project]
dependencies = ["httpx"] # picks up arbitrary future versions
# 2. Gratuitous upper bound
dependencies = ["httpx<1.0"] # blocks downstream when httpx 1.0 ships
# add upper bound only when documented incompatibility
# 3. Pinned exactly in runtime
dependencies = ["httpx==0.27.2"] # ZERO room for downstream resolution
# 4. Dev dep in runtime
dependencies = ["pytest>=8.0"] # forces every consumer to install pytest
# 5. Build dep in runtime
dependencies = ["hatchling>=1.27"] # build backend; not needed at runtime
# 6. Copyleft dep
dependencies = ["gpl-licensed-pkg>=2.0"] # blanket rejection
# 7. Unmaintained dep with CVEs
dependencies = ["py>=1.0"] # archived 2023; CVE-2022-42969
# 8. Typosquat
dependencies = ["requsts>=2.0"] # 1-letter typo of `requests`
# 9. Missing optional groups
[project]
dependencies = ["httpx", "pytest"] # pytest in runtime; should be optional dev
# 10. No lockfile committed
# (uv.lock / poetry.lock / requirements.lock not in repo)
# Reproducible builds require pinned transitives; lockfile is the contract.
Cross-references
python-sdk-config-pattern— when to add pydantic (a vetted dep) vs stick with@dataclass.python-mypy-strict-typing— type-stub deps (types-<name>) often need their own vetting.python-asyncio-patterns— choosing between aiohttp and httpx (both vetted; httpx wins on transitive count).python-otel-instrumentation—opentelemetry-apivsopentelemetry-sdk(library uses API only; SDK is the consumer's choice).sdk-dep-vet-devil-python— agent that renders verdicts using these checks.sdk-convention-devil-pythonC-15 — dependency LIST shape rules (lower bounds, optional-dependencies groups).sdk-packaging-devil-pythonP-7 — runtime vs dev dep placement.