Deep technical companion to CLAUDE.md. CLAUDE.md is the
contributor contract (conventions, do/don't); this file explains how the
thing actually works so you can navigate and extend it without spelunking.
Read CLAUDE.md first for the rules, then this for the map.
Everything here describes the codebase only — no machine- or deployment- specific setup. Audience: a developer (human or agent) opening the repo cold.
cli.main
└─ _cmd_audit
├─ discover_one(path) # single project? (discovery.py)
│ └─ else discover_tree(path) # walk a tree, prune venvs/junk
├─ PyPIClient / VulnClient # built once, shared across projects
└─ for each Project:
audit_project(project, pypi_client, vuln_client, current_python) # analysis.py
├─ _collect_deps(project) # parsing.py → list[Dep]
├─ pypi_client.fetch_many(names) # PyPI metadata (urllib + cache)
├─ _pick_latest / classify_drift # per dep
├─ classify_pin_status # per dep
├─ vuln_client.fetch_for(pairs) # pip-audit subprocess + cache
├─ suppressions.load_suppressions # accepted-risk filter
└─ → ProjectAudit (frozen DepAudits + per-project rollups)
└─ render_* (terminal tree/table/summary | json | sarif) # render/
└─ gate evaluation → exit code
The pipeline is read-only. update and bootstrap are the only write
paths and live in their own modules.
Key property: one bad project never kills the scan. _cmd_audit catches
broadly per project and logs a warning; network/subprocess misses degrade to
UNKNOWN / *_unreachable rather than raising.
| Module | Owns | Entry points |
|---|---|---|
cli.py |
argparse wiring, dispatch, gates, exit codes, filters, progress | main, build_parser, _cmd_*, _exceeds_threshold, _exceeds_age_threshold, _vuln_gate_tripped, _filter_audits |
discovery.py |
walk tree, find projects, prune venvs/junk, detect python version | discover_one, discover_tree |
parsing.py |
parse every dep-file format → list[Dep] |
parse_source, _collect_deps (via analysis) |
models.py |
frozen dataclasses + enums; no logic | Dep, DepAudit, ProjectAudit, SemverDrift, PinStatus, Vulnerability, … |
pypi.py |
PyPI JSON metadata, urllib + on-disk TTL cache + threadpool | PyPIClient.fetch_one/fetch_many, _parse_pypi_payload |
vulns.py |
pip-audit subprocess, per-(name,version) cache, advisory dedup |
VulnClient.fetch_for, _parse_pip_audit_payload, compute_min_safe_version, _dedupe_vulns |
suppressions.py |
[tool.piptastic.suppressions] loader + matcher |
load_suppressions, find_rule, SuppressionRule |
analysis.py |
the core read pipeline — turns a Project into a ProjectAudit | audit_project, classify_drift, classify_pin_status, _pick_latest, _upload_time_for, _pinning_score |
update.py |
rewrite requirements*.txt with backup + test install + rollback |
update_project |
bootstrap.py |
freeze a venv to requirements.txt |
find_venv, freeze_venv, find_site_packages, is_plumbing |
stats.py |
cross-project rollup | compute_stats |
render/terminal.py |
rich tree/table/summary + multi-project footer | render_terminal, render_stats_terminal |
render/json_out.py |
stable schema_version JSON |
render_json, render_stats_json, SCHEMA_VERSION |
render/sarif.py |
SARIF 2.1.0 for GitHub Code Scanning | render_sarif |
logging.py |
logger factory (stderr + optional file mirror) | get_logger, configure_logging |
Tests mirror sources 1:1 under tests/; fixtures are real directory trees
under tests/fixtures/.
All model dataclasses are frozen except ProjectAudit (mutated only by
_filter_audits reconstruction). Mutate by constructing a new instance; new
fields get defaults so older call sites keep working.
Core shapes:
Dep— a parsed requirement:name(canonicalized),raw_name,specifier(SpecifierSet),extras,marker,source(DepSource),url. Dumb container.DepAudit—Dep+ resolved facts:latest,latest_including_prereleases,drift,pin_status,yanked,vulnerabilities,min_safe_version,latest_release_date,warnings.ProjectAudit—project,deps,pinning_score,drift_summary,yanked_count,pypi_unreachable,vuln_count,vuln_unreachable,suppressed_count.
Invariants worth preserving (there are tests that assert these; keep them true):
ProjectAudit.vuln_count== number of non-suppressed advisories summed overdeps. (Tree-wide CVE totals aresum(vuln_count).)- No
DepAudit.vulnerabilitieslist contains duplicate advisory ids — they are deduped invulns.py(see §6). min_safe_version, when set, is one of the advisories' fix versions and is strictly greater than the current pin.latest_release_dateandlatest_release_age_daysare both-or-neither.pin_status/driftvalues are always valid enum members.- Output is deterministic given the same inputs (projects sorted by name,
dedup preserves first-seen order,
_pick_latestis order-independent). pinning_scoreis computed over direct deps only (a.dep.direct), so a lockfile's always-pinned transitive graph doesn't force every locked project to ~100%.
discover_tree walks with os.walk, pruning in place: ALWAYS_SKIP dirs,
exact venv names, any dir containing pyvenv.cfg, *.egg-info, plus
user --exclude globs (matched against basenames, not full paths).
A directory is a project if it has requirements*.txt / constraints*.txt,
a pyproject.toml with [project] or [tool.poetry], or a Pipfile. Each
DepSource records its kind, path, and group (e.g. default, dev,
an extras name, a poetry group). Python version is sniffed from runtime.txt,
requires-python, or Pipfile [requires].
discover_one treats the path as a known project root (no parent rescan) —
this is why audit <single-project> defaults to the table view.
parse_source(DepSource) dispatches on kind. Notable internals:
- Encoding: requirements files are read as bytes, then decoded by
_decode_requirements: UTF-32 BOM → UTF-16 BOM →utf-8-sig→ latin-1 last resort. The BOM checks matter — Windowspip freeze >writes UTF-16-LE, and the latin-1 fallback would otherwise turn it intoF\x00l\x00…garbage and silently drop every dep. - Includes:
-r other.txt/-c constraints.txtare followed recursively with a_visitedset for cycle detection; eachDepkeeps theDepSourceof its true origin file. - PEP 508 via
packaging.Requirement. Bare direct-URL / VCS lines aren't PEP 508, so_rewrite_bare_urlrewrites them toname @ url: it prefers an explicit#egg=name, else derives the name from the repo path (_name_from_vcs_url: strip trailing@refand.git). Result isURLposture. - Poetry / Pipfile shorthand share
_poetry_to_pep508, which returns a list (Poetry multiple-constraints deps are a list of{version, markers}tables → one PEP 508 string per entry). It expands caret/tilde, honors a rawmarkersstring and/or thepythonshorthand (parenthesized + AND-joined). Both the Poetry and Pipfile callers iterate the returned list — if you change its return type, update both. - Lockfiles (
uv.lock/poetry.lock/pdm.lock) go through_parse_lockfile: all three are TOML with[[package]]arrays, so it readsdata["package"]and emits each as an exact==pin (the full resolved graph, direct + transitive)._lockfile_direct_namesreads the siblingpyproject.tomlto compute the direct-dep set and tags eachDep.directaccordingly (returnsNone= "unknown" when there's no manifest, so entries degrade to direct). The local project's own editable/virtual entry is skipped. Discovery suppresses the matching manifest source when a lock is present (discovery._dep_sources_in_dir: poetry.lock → skip Poetry source; uv/pdm.lock → skip PEP 621 source) so packages aren't double-counted.
Both follow the same shape (a deliberate convention — mirror it for any new
network/subprocess client): constructor takes cache_dir, ttl_seconds,
timeout, concurrency; on-disk JSON cache; swallow-and-log on failure;
return None/() on miss.
PyPIClient — stdlib urllib only (never requests/httpx). Parses the
JSON releases map into ReleaseInfo per version (version, yanked,
requires_python, upload_time). Cache is per-distribution JSON, bucketed by
name.
VulnClient — invokes python -m pip_audit (not the pip-audit script;
the script isn't on Windows PATH after install) as a subprocess against a temp
requirements file. Important details:
_chunk_unique_by_namesplits pins into batches with at most one version per package name — pip-audit rejects requirement files with duplicate names even at different versions._parse_pip_audit_payload→_dedupe_vulns: pip-audit/OSV emits the same advisory id once per affected version range._dedupe_vulnscollapses by id, unioningfix_versionsandaliases(first non-empty description wins). Dedup also runs in_rehydrate_vulnsso pre-fix caches self-correct on read.- pip-audit exit code
1means "vulns found" (expected); only!= 0 and != 1is treated as failure. On failure the package goes tounreachable, never silently "clean". - Cache key is
(canonical_name, version), file bucketed bysha1[:2]. Empty results are cached too, so clean pins don't re-spawn the subprocess.
compute_min_safe_version(installed, vulns) = max over advisories of the
lowest fix strictly greater than installed; None if any advisory has no
known newer fix (can't recommend one).
audit_project orchestrates everything above into a ProjectAudit.
_pick_latest(md, target_python)returns(latest_stable, latest_incl_pre), excluding yanked and python-incompatible releases.--include-prereleasesswaps in the prerelease candidate as the effective latest.classify_drift(current, latest)→SemverDriftby which segment moved:NONE<BUILD<PATCH<MINOR<MAJOR<EPOCH;UNKNOWNwhen either side is missing (PyPI miss, unpinned, URL).classify_pin_status(specifier, url)→ posture from the specifier shape (not value):PINNED/COMPATIBLE/RANGE/FLOOR/UNPINNED/URL._pinning_score= fraction of non-URL deps that arePINNED/COMPATIBLE;None(→n/a) when every dep isURL.- Age signal:
_upload_time_for(md, effective_latest)attaches the upload time of the selected latest version tolatest_release_date. This is what--fail-on-ageand theAgecolumn read; it surfaces alive-vs-abandoned independent of drift. - Suppressions are applied here: a matched
SuppressionRulemarks an advisorysuppressed=True(excluded fromvuln_count,--fail-on-vuln, and the update CVE floor) but the advisory is still emitted in JSON/SARIF.
- Exit codes are named constants — never write literal
1/2/3outside this module:EXIT_OK=0,EXIT_ERROR=1(operational),EXIT_ROLLBACK=2(updatetest-install rolled back),EXIT_GATE=3(policy gate tripped). - Gates run on the unfiltered audits (filters are display-only):
--fail-on-drift LEVEL,--fail-on-vuln any|N,--fail-on-age DAYS._exceeds_age_thresholduses strict>and fails open on unknown dates. - Filters (
--vulnerable-only,--drift-min) shrink the displayeddepsvia_filter_audits, dropping emptied projects; per-project counters are preserved. When a filter empties everything, the terminal branch reports "No deps matched … across N project(s) scanned" rather than "no projects". - Progress bar is shown only when
len(projects) > 1 and stdout.isatty() and not json/sarif and not quiet. It writes to stderr so stdout stays clean for--json/--sarif. Bothauditandstatsuse_make_progress. - argparse with subparsers — no click/typer. Shared cache flags
(
--no-cache,--refresh-cache,--cache-ttl,--concurrency) are copied onto each subcommand that does lookups.
- terminal: three views —
tree(default multi-project),table(default single project),summary(one row per project; theOthercolumn folds build+epoch drift so it isn't hidden). A multi-project render ends with an aggregate footer (project/dep counts + CVE/yanked totals when non-zero). Color is paired with text labels everywhere (no color-only signaling), and_make_consolesetssafe_box=Truewhen stdout can't encode box-drawing (cp1252). rich honorsNO_COLOR. - json_out: exports
SCHEMA_VERSION. Field rename/removal/type change ⇒ bump it; additive keys don't (but record them in the README schema-version table). Top-level discriminator iskind(audit/stats). - sarif: one rule per advisory id, one result per (dep, advisory);
suppressed advisories carry
suppressions: [{kind: "external"}]. Mutually exclusive with--json.
| Source | Layout | Key |
|---|---|---|
| PyPI | <cache>/piptastic/pypi/… |
per distribution name |
| vulns | <cache>/piptastic/vulns/<sha1[:2]>/<sha1>.json |
(name, version) |
Parent overridable with PIPTASTIC_CACHE_DIR; else XDG_CACHE_HOME else
~/.cache. Default TTL 3600s. --no-cache/--refresh-cache both set TTL=0.
Cache entries round-trip through _dehydrate_*/_rehydrate_*; preserve
timezone-aware datetimes across the round-trip (the age signal depends on
it). Don't change the on-disk layout without considering existing user caches.
- Mock the HTTP/subprocess boundary, not the cache. PyPI:
patch.object(client, "_http_get", ...)or inject aFakeClientwithfetch_many/fetch_one. Vulns:patch.object(client, "_run_pip_audit", ...)or aFakeVulnClientwithfetch_for+unreachable. - Fixtures are real trees under
tests/fixtures/. Add one rather than building a tree inline when the shape will be reused. Encoding-specific cases (UTF-16) are better written totmp_pathas bytes to dodge.gitattributesEOL normalization. caplogdoes NOT capture piptastic logs by default.logging.pysetslogger.propagate = False, socaplog(which listens on root) sees nothing onceconfigure_logginghas run. Either assert on stderr viacapsys(aftermain()configures the stderr handler) or attachcaplog.handlerdirectly to thepiptasticlogger in the test.- "Test passes" ≠ "code ran." The progress-bar branch only executes under
stdout.isatty(), which is false undercapsys; a naiveisattymonkeypatch is ignored. Force it with a tty-proxy stdout (isatty()->True, delegating writes) and confirm via coverage that the intended lines were hit. - Run the full suite before committing — shared helpers bite. (Example:
changing
_poetry_to_pep508's return type broke the Pipfile parser, which reuses it.) All tests must pass; new behavior needs a test that would fail without the change.
- Production is Linux; primary dev is Windows. No shell-only assumptions, no
Windows-only path tricks;
.gitattributespins line endings. - pip-audit is invoked as
python -m pip_audit(the script isn't on Windows PATH afterpip install). - Terminal renderer falls back to
safe_box=Trueon cp1252 stdout. Don't add box-drawing/ellipsis characters that break that fallback. - Requirements files may be UTF-16 (Windows
pip freeze >); the parser detects the BOM (§5).
Add a new dep-file format
- Add a
SourceKind; emitDepSources for it indiscovery.py. - Add a
_parse_<format>inparsing.py; dispatch it inparse_source. - Normalize to PEP 508 where possible and reuse
_parse_one_requirement_line. - Fixture tree + parsing tests.
Add a CI gate
- Add the flag in
build_parser; validate its value early in_cmd_audit. - Add an
_exceeds_*/*_gate_trippedhelper evaluating the unfiltered audits; setgate_trippedand returnEXIT_GATE. - Document in README flag table + Exit codes; CHANGELOG; test the trip and the clean (fail-open) path.
Add a JSON field
- Add it in
render/json_out.py. Additive ⇒ no schema bump but record it in the README schema-version-history table. Rename/remove/retype ⇒ bumpSCHEMA_VERSION. - If terminal-relevant, update all three terminal views and SARIF.
Add a subcommand
- New subparser in
build_parser; copy the shared cache-flag block if it does lookups. New write paths need an opt-in flag, a backup, and a rollback. _cmd_<name>dispatched frommain; reuseEXIT_*.
pip install -e ".[dev]"
pytest tests/ # all green before any commit
piptastic audit . # dogfood on this repo (or a tree of projects)Release steps live in CLAUDE.md ("How to make a release"): bump
pyproject.toml + __init__.__version__ in lockstep, move CHANGELOG
[Unreleased] into a dated section, tag, push, cut a GitHub release. Commit
hygiene (no attribution trailers, conventional subjects, SPDX headers on new
source files) is also in CLAUDE.md.