feat: establish sbom nexus extraction slice

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 12:50:08 +02:00
parent 79cd7dff06
commit d61698ea51
31 changed files with 3246 additions and 1 deletions

View file

@ -0,0 +1,6 @@
# Repository boundary
Read `INTENT.md` before architectural changes. SBOM Nexus owns SBOM snapshot
history, scanning, evaluation, and catch-up. Repo Manager owns repository
representation; Activity Core owns recurrence; State Hub is a temporary
compatibility consumer/projection. Do not rebuild those systems here.

View file

@ -0,0 +1,17 @@
# Stack and commands
SBOM Nexus is a Python 3.11+ FastAPI service packaged with `uv` and Hatchling.
The extraction slice uses SQLite through the standard library; PostgreSQL is a
production-cutover gate, not an implied property of the current code.
```bash
make install
make test
make lint
make check
make run
make scan
```
Do not claim verification without running `make check` or naming the narrower
checks that were actually run.

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.git
.venv
.pytest_cache
.ruff_cache
__pycache__
*.db
tests
docs
workplans

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
build/
dist/
.env
*.db
*.db-shm
*.db-wal
.claude/*
!.claude/rules/
!.claude/rules/*.md

23
.repo-classification.yaml Normal file
View file

@ -0,0 +1,23 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-08-22'
classified_by: codex
category: tooling
domain: infotech
secondary_domains: []
capability_tags:
- traceability
- compliance
- risk
- audit
- operations
business_stake:
- technology
- operations
- automation
- legal
business_mechanics:
- operation
- control
notes: Dedicated SBOM capture, history, evaluation, and bounded catch-up service.

66
AGENTS.md Normal file
View file

@ -0,0 +1,66 @@
# SBOM Nexus — Agent Instructions
## Repository identity
**Purpose:** Dedicated SBOM capture, history, evaluation, and bounded catch-up service.
**Domain:** infotech
**Repo slug:** sbom-nexus
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `SBOM-WP-`
Read `INTENT.md`, `SCOPE.md`, and `ProductRequirementsDocument.md` before
changing product or ownership boundaries. Repository/build evidence is scan
input; SBOM Nexus is authoritative for normalized snapshot history and
freshness evaluation.
## State Hub integration
Use the REST API at `http://127.0.0.1:8000` (remote tunnel `:18000`) for
coordination. The canonical health endpoint is `/state/health`. If loopback is
blocked by a sandbox, retry with approved elevated network access before
declaring it unavailable.
At session start:
```bash
cat .custodian-brief.md
curl -s 'http://127.0.0.1:8000/messages/?to_agent=sbom-nexus&unread_only=true'
ls workplans/
```
At session close, update file-backed task states, run `statehub
fix-consistency`, and record a progress event. Workplan files are authority;
State Hub is a replaceable projection.
## Commands
```bash
uv sync --dev
uv run pytest
uv run ruff check src tests
uv run sbom-nexus scan .
uv run sbom-nexus serve --reload
```
## Architecture constraints
- Keep scanner/parsers independent of HTTP and persistence.
- Every ingest attempt creates an immutable snapshot with an explicit outcome.
- Store both last-attempt and last-success timestamps; do not overload success.
- Preserve the legacy State Hub API only through tested compatibility handlers.
- Catch-up must be deterministic and bounded to 1..25 targets.
- Do not turn simple licence substring matching into an unqualified legal claim.
- Do not add repository control or recurrence here; integrate with Repo Manager
and Activity Core.
- Never store credentials in repository files, snapshots, logs, or workplans.
## Workplan convention
Workplans live at `workplans/SBOM-WP-NNNN-<slug>.md`. Status values are
`proposed | ready | active | blocked | backlog | finished | archived`; task
status values are `wait | todo | progress | done | cancel`. Before finishing a
workplan, turn actionable residuals into a follow-up workplan or intake.

8
CLAUDE.md Normal file
View file

@ -0,0 +1,8 @@
# SBOM Nexus — Claude Code Instructions
@AGENTS.md
@INTENT.md
@SCOPE.md
@ProductRequirementsDocument.md
@.claude/rules/stack-and-commands.md
@.claude/rules/repo-boundary.md

17
Containerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock README.md /app/
COPY src /app/src
RUN pip install --no-cache-dir .
RUN useradd --create-home --uid 10001 sbom-nexus \
&& mkdir -p /data \
&& chown sbom-nexus:sbom-nexus /data
USER sbom-nexus
ENV SBOM_NEXUS_DATABASE_PATH=/data/sbom-nexus.db
EXPOSE 8010
CMD ["sbom-nexus", "serve", "--host", "0.0.0.0", "--port", "8010"]

112
INTENT.md Normal file
View file

@ -0,0 +1,112 @@
# Intent — SBOM Nexus
## Enduring purpose
SBOM Nexus exists to make the software composition of every managed product
knowable, historically traceable, and operationally maintainable.
Its job is not merely to generate an SBOM file. It turns repository and build
evidence into a durable product surface: versioned component inventories,
provenance, licence evaluation, freshness signals, and bounded work queues that
keep coverage improving without flooding people or task systems.
## Problem
Software composition evidence is currently split across repository lockfiles,
an interim Repo Manager scanner, and State Hub tables, routes, scripts, MCP
tools, and dashboards. State Hub is being retired and is the wrong permanent
owner for a supply-chain product. Its fleet-wide freshness automation also
demonstrated the wrong control shape: one weekly evaluation created dozens of
tasks while updating no SBOMs.
Without a dedicated owner, the fleet risks stale inventories, lost history,
inconsistent parser behavior, vague licence findings, and automation that
measures a backlog instead of reducing it.
## Product responsibility
SBOM Nexus is the authoritative service for:
- normalized SBOM snapshots and their immutable history;
- package/component entries and source provenance;
- repository scanning and ingestion of supported evidence formats;
- licence inventory and clearly qualified risk signals;
- SBOM freshness, coverage, and deterministic oldest-first catch-up ranking;
- explicit terminal outcomes for scans that cannot run or find no manifests;
- stable HTTP and operator interfaces for fleet consumers;
- migration of historical State Hub SBOM records without erasing provenance.
## Authority boundaries
| Concern | Authority |
| --- | --- |
| Dependency declarations, lockfiles, reviewed `sbom-tools.yaml`, supplied SPDX/CycloneDX documents | The owning repository or build output |
| Normalized snapshot history, scan attempts, entries, evaluation, freshness, catch-up order | SBOM Nexus |
| Repository identity, classification, active status, and checkout representation | Repo Manager |
| Scheduling and bounded recurring execution | Activity Core |
| Cross-domain work coordination and transitional `last_sbom_at` projection | State Hub during cutover only |
| Large immutable source documents or attestations | Artifact Store when introduced |
Repo Manager may observe dependency sources and invoke SBOM Nexus, but it does
not remain a second SBOM product or historical store. State Hub may proxy Nexus
responses during migration, but it must not regain permanent SBOM ownership.
## Operating principles
1. **Evidence before assertion.** Every component is traceable to a source path,
source digest, submitted document, and—when available—source revision.
2. **History is append-only.** A new scan creates a snapshot. It does not mutate
a prior view of the repository.
3. **Absence is explicit.** No checkout, no supported manifest, and parser
failure are recorded outcomes, not indistinguishable missing data.
4. **Automation reduces queues.** Catch-up selects at most N oldest eligible
repositories and advances every selected item through success or a recorded
skip; it never emits a task per stale repository.
5. **Compatibility is temporary and testable.** Existing State Hub callers get
a pinned compatibility contract while they are retargeted.
6. **Standards are interchange boundaries.** SPDX and CycloneDX import/export
are first-class targets; the internal model is not presented as either
standard unless it satisfies that standard.
7. **Risk signals remain qualified.** Licence-family pattern matching is a
triage signal, not legal advice or a complete SPDX policy engine.
8. **Repository sources remain reconstructable authority.** Derived current
snapshots can be rebuilt from current source evidence; imported historical
snapshots retain their original source and timestamps.
## Intended users and outcomes
- Operators can see which repositories have current evidence and safely catch
up a bounded number each day.
- Developers can scan a checkout locally and understand the exact source and
parser behind each component.
- Compliance and security consumers can query fleet composition and licence
signals without coupling to State Hub internals.
- Automation can distinguish successful ingest, empty evidence, unavailable
checkout, and ingest error, and can report each deterministically.
- Future vulnerability, policy, signing, and attestation capabilities have a
coherent SBOM history on which to build.
## Success horizon
SBOM Nexus succeeds first when the existing State Hub SBOM surface is served by
this repository with preserved history and the daily oldest-N workflow operates
without task floods. It succeeds durably when SBOM evidence is current by
policy, standard documents can move in and out without information loss, and no
other service acts as a competing SBOM authority.
## Non-goals
- Owning repository lifecycle, workplans, inboxes, or general task management.
- Acting as a package registry, artifact store, or source-code host.
- Claiming that lockfile parsing alone is a complete runtime or deployable SBOM.
- Providing legal conclusions from licence identifiers.
- Building a general vulnerability database in the extraction milestone.
- Scanning an unbounded fleet in one recurring activity.
## Change test
A change belongs here when it improves the capture, preservation, evaluation,
exchange, or operational freshness of software-composition evidence. If it is
primarily about repository control, scheduling, artifact bytes, work
coordination, or authorization, SBOM Nexus should integrate with the owning
component instead of absorbing that responsibility.

151
LICENSE Normal file
View file

@ -0,0 +1,151 @@
# Target Revenue Source License
**Version 1.0, Candidate 1 (V1C1)**
---
> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL**
>
> This repository is governed by the Target Revenue Source License
> (TRSL), Version 1, Candidate 1. This is the framework's first working
> candidate, adopted as the org's preliminary operating license across
> all repos (maintainer decision, 2026-07-29) during the build/alpha
> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link
> below) for the alpha/beta risk-acceptance decision this reflects and
> what it does and does not mean. Full specialist legal review is
> explicitly deferred until the framework moves out of beta.
>
> **Canonical source, full candidate-status banner, and Appendix A
> (non-normative candidate notes tracking every open item):** the
> `coulomb` org's `target-revenue` repository,
> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative
> legal text (Preamble through Section 11) only; the canonical document
> is authoritative if this copy and that document ever diverge.
---
## Preamble
This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License.
Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source.
## 1. Definitions
Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept.
**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase.
**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`.
**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement.
**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service.
**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger.
**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event.
**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest.
**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release.
**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest.
**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character.
**"Outstanding Target"** means, at any time, `max(0, Initial Target cumulative Development Credit cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger.
**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest.
**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification.
**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger.
**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension.
**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest.
**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification.
**"You"** or **"Licensee"** means the individual or entity exercising rights under this License.
## 2. Grant of Rights for Noncommercial Use
Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to:
(a) use, reproduce, and study the Software for any Noncommercial Use;
(b) modify the Software and create derivative works of it for any Noncommercial Use; and
(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software.
This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3.
## 3. Commercial Use
You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so.
This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement.
## 4. Patent License
Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software.
If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed.
## 5. Automatic Conversion to the Future License
**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party.
**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software.
**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release.
**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger.
## 6. Successive Phases
The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release.
## 7. Term and Termination
**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License.
**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity.
**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2.
**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification.
A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle.
Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party.
## 8. Disclaimer of Warranty
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT.
## 9. Limitation of Liability
IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW.
## 10. Trademarks
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution.
## 11. General Provisions
**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2.
**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable.
**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision.
**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License.
**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License.
---
**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 27 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`.

20
Makefile Normal file
View file

@ -0,0 +1,20 @@
.PHONY: install test lint check run scan
export UV_CACHE_DIR ?= /tmp/sbom-nexus-uv-cache
install:
uv sync --dev
test:
uv run pytest
lint:
uv run ruff check src tests scripts
check: lint test
run:
uv run sbom-nexus serve --reload
scan:
uv run sbom-nexus scan .

View file

@ -0,0 +1,259 @@
# Product Requirements Document — SBOM Nexus
**Status:** initial extraction baseline
**Date:** 2026-08-22
**Primary coordination:** `CUST-WP-0062`
**Initial implementation workplan:** `SBOM-WP-0001`
## 1. Product summary
SBOM Nexus is a headless service that captures software-composition evidence,
normalizes it into immutable per-repository snapshots, reports licence and
coverage signals, and selects a bounded oldest-stale queue for recurring
catch-up. It replaces State Hub as the SBOM store and product API and supersedes
Repo Manager's interim in-process scanner as the durable product owner.
## 2. Background
The current State Hub implementation provides snapshot and entry tables,
lockfile parsers, manual ingest, latest-snapshot queries, licence grouping,
MCP tools, dashboard views, and a `last_sbom_at` repository projection. A later
retirement slice reproduced the source-derived scanner in Repo Manager.
The operational trigger for extraction is `CUST-WP-0062`: a weekly freshness
rule created 75 tasks on 2026-08-17 while 111 of 111 registered repositories
were stale or never scanned. The desired control loop evaluates the fleet once,
selects the N oldest eligible repositories, and attempts no more than N updates
per run. N defaults to 3.
## 3. Goals
- Establish SBOM Nexus as the single durable SBOM product owner.
- Preserve State Hub's existing snapshot history and compatibility surface.
- Reuse and improve the repository-derived parsers already proven in State Hub
and Repo Manager.
- Provide one efficient, deterministic oldest-N catch-up query.
- Record success, empty evidence, unavailable checkout, and ingest failure so a
permanently impossible repository cannot block the queue.
- Keep `last_sbom_at` projections truthful throughout cutover.
- Give Activity Core a bounded read and ingest contract that can replace the
disabled weekly task-flood rule.
## 4. Non-goals for the extraction release
- Vulnerability correlation or exploitability analysis.
- Full licence-policy adjudication or legal advice.
- Adding ecosystems beyond those already handled by the source implementation.
- Build-time signing, attestations, or admission control.
- A new human dashboard; existing views are retargeted during cutover.
- An unbounded background scanner owned by SBOM Nexus.
## 5. Users and jobs
| User | Job |
| --- | --- |
| Fleet operator | Find and update the oldest stale SBOMs without creating an unbounded workload |
| Repository maintainer | Scan a checkout, see parser errors and provenance, and retrieve the current snapshot |
| Activity Core | Fetch at most N ordered targets and record bounded ingest/skip outcomes |
| State Hub compatibility clients | Continue using the current `/sbom/*` read and ingest contract during migration |
| Compliance/security consumer | Query components, licence distribution, and direct production copyleft triage signals |
| Migration operator | Import historical snapshots while preserving ids, timestamps, sources, and repository association |
## 6. Functional requirements
### FR-1 Repository representation
The service shall maintain the minimum repository projection required for SBOM
operations: stable slug, active/in-scope flag, optional checkout path, last
attempt time, last successful ingest time, and latest outcome/source. Repo
Manager remains upstream authority for repository identity and paths.
### FR-2 Source detection and parsing
The extraction release shall detect sources recursively while excluding
dependency/build directories. It shall support:
- `uv.lock` and `requirements.txt` (Python);
- `package-lock.json` and `yarn.lock` (Node);
- `Cargo.lock` (Rust);
- `go.sum` with `go.mod` direct-dependency hints;
- `.terraform.lock.hcl`;
- `ansible/requirements.yml` and `.yaml` below an `ansible` directory;
- reviewed root `sbom-tools.yaml`.
Every detected source shall have a repository-relative path, SHA-256 digest,
entry count, and any parser error. A repository Git revision is captured when
available.
### FR-3 Immutable snapshots
Every manual ingest, checkout scan, skip, error, or historical import shall
create a new immutable snapshot. Entries belong to exactly one snapshot.
Previous snapshots remain queryable. Snapshot status and source distinguish at
least `ingested`, `no-checkout`, `no-manifest`, `ingest-error`, and `imported`.
### FR-4 Current inventory queries
The service shall return:
- all snapshots, optionally filtered by repository;
- a snapshot and its entries;
- entries from the latest snapshot per repository with existing State Hub
filters (`ecosystem`, `license_spdx`, `is_direct`, `is_dev`);
- the latest repository SBOM view;
- fleet and per-repository freshness metadata.
### FR-5 Licence report
The extraction release shall group latest-snapshot entries by SPDX licence
text, identify simple copyleft-family triage matches, and count/list direct
non-development matches. The API and documentation shall label this as a signal
rather than a complete SPDX expression or legal-policy evaluation.
### FR-6 Bounded catch-up
`GET /sbom/catch-up?limit=N` shall:
- default N to 3 and constrain it to 1..25;
- consider only active/in-scope repositories;
- use a configurable stale threshold, initially 30 days;
- order never-attempted repositories first, then oldest attempt, then slug for
deterministic ties;
- return no more than N repositories;
- include `stale_count`, `never_count`, `total_count`, and effective `limit`;
- return per target `repo_slug`, `last_sbom_at`, `sbom_age_days`, `has_sbom`,
and `checkout_available` when known.
### FR-7 Bounded ingest and skip
A repository ingest operation shall use the registered checkout unless an
explicit authorized path is supplied. It shall create one of these terminal
outcomes:
- `ingested`: supported sources parsed and snapshot stored;
- `no-checkout`: no reachable checkout;
- `no-manifest`: checkout exists but no supported source exists;
- `ingest-error`: one or more sources could not be parsed.
Every outcome advances the repository's last-attempt time. Only `ingested`
advances last-success time. This prevents a permanently unavailable repository
from remaining first forever without falsely claiming a successful SBOM.
### FR-8 Compatibility
During cutover, SBOM Nexus shall preserve the response intent of State Hub's:
- `POST /sbom/ingest/`;
- `GET /sbom/`;
- `GET /sbom/{repo_slug}`;
- `GET /sbom/snapshots/` and `/sbom/snapshots/{id}`;
- `GET /sbom/report/licences/`.
Differences required for provenance or correctness shall be additive and
documented. State Hub may proxy these routes until callers move.
### FR-9 Historical migration
An import tool shall read historical State Hub snapshots in chronological
order and recreate their repository association, timestamp, source, entries,
and legacy identifier provenance. Import shall be idempotent. Migration must be
verified before State Hub rows are retired.
### FR-10 Operator interfaces
The repository shall provide commands to run the API, scan a checkout locally,
run tests/lint, and import State Hub history. Configuration shall be supplied
through explicit environment variables and contain no secrets in source.
## 7. Data model
```text
Repository 1 ─── * Snapshot 1 ─── * Entry
└── source records/errors/provenance
```
- **Repository:** slug, active, checkout path, last attempt/success, last status.
- **Snapshot:** UUID, repository, timestamp, status/source, source revision,
entry count, legacy id, structured errors.
- **Entry:** package name/version, ecosystem, licence text, direct/dev flags,
source path.
The initial local implementation may use SQLite for extraction tests and
single-node operation. Production cutover requires PostgreSQL migrations,
backup/restore evidence, and explicit retention settings.
## 8. API contract
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/state/health` | Liveness and store readiness |
| PUT | `/repositories/{slug}` | Upsert the minimum Repo Manager projection |
| GET | `/repositories/` | List repository SBOM status |
| GET | `/sbom/catch-up?limit=N` | Select bounded oldest-stale targets |
| POST | `/sbom/{slug}/ingest` | Scan registered checkout and record terminal outcome |
| POST | `/sbom/{slug}/skip` | Record an explicit bounded skip outcome |
| POST | `/sbom/ingest/` | State Hub-compatible entry ingest |
| GET | `/sbom/` | Latest entries with filters |
| GET | `/sbom/{slug}` | Latest repository view |
| GET | `/sbom/snapshots/` | Snapshot history |
| GET | `/sbom/snapshots/{id}` | Snapshot detail |
| GET | `/sbom/report/licences/` | Latest-snapshot licence report |
## 9. Quality and operational requirements
- Catch-up ordering and bounds are covered by deterministic tests.
- A recorded skip demonstrably advances queue position.
- Ingest is transactional: a snapshot and its entries appear together.
- Paths returned by APIs are repository-relative unless explicitly operator-only.
- Parser failure in one source is visible; it must not silently produce a
successful snapshot.
- The API must not follow paths supplied by an untrusted remote caller in the
production profile; registered checkout paths are operator-managed inputs.
- Structured logs and metrics shall distinguish attempts, successes, skips,
errors, duration, and entry counts before production cutover.
- Backups and a restore drill are required before historical authority moves.
## 10. Migration and rollout
1. Establish this repo, intent, PRD, inventory, package, and tests.
2. Implement the compatible API, scanner, persistence, and catch-up contract.
3. Register/synchronize repository projections from Repo Manager.
4. Import and reconcile State Hub history; compare repository/snapshot/entry
counts and sampled licence reports.
5. Put State Hub `/sbom/*` behind a proxy/strangler and retarget MCP/dashboard.
6. Enable Activity Core's bounded ingest side effect, then its disabled daily
definition; retain the weekly flood disabled.
7. Observe at least two successful production fires and verify at most N
terminal outcomes per fire.
8. Remove State Hub ownership only after compatibility and retention gates pass.
## 11. Acceptance criteria
- Existing parser fixtures and State Hub-compatible route behaviors pass in
SBOM Nexus.
- `limit=3` returns exactly the three oldest eligible stale repositories when
at least three exist, with never-attempted repositories first.
- Recording a no-checkout or no-manifest outcome removes that repository from
the head of the immediate queue.
- Historical State Hub snapshot and entry counts reconcile, with documented
treatment of any invalid legacy row.
- State Hub is no longer the permanent SBOM store.
- Activity Core performs no more than N ingests/skips per fire and spawns zero
per-repository catch-up tasks.
- `last_sbom_at` compatibility projections are derived from a documented Nexus
timestamp and remain truthful.
## 12. Deferred product decisions
- Canonical component identity and deduplication across ecosystems.
- Full SPDX expression parsing and policy ownership.
- SPDX/CycloneDX import/export profiles and conformance level.
- Vulnerability, VEX, attestation, signing, and deployment correlation.
- Artifact Store integration and snapshot retention policy.
- Multi-tenant authorization via Tenant Engine and Flex Auth.
- Whether `last_sbom_at` compatibility maps to last attempt or last successful
inventory; Nexus retains both so consumers can migrate to precise fields.

View file

@ -1,3 +1,32 @@
# sbom-nexus
A sbom management service
SBOM Nexus is the dedicated software-bill-of-materials service for the Coulomb
fleet. It captures dependency evidence from repositories, keeps immutable
snapshots, evaluates licence risk and freshness, and provides a bounded catch-up
queue for automation.
The product direction is defined in [INTENT.md](INTENT.md). The first delivery
contract is in [ProductRequirementsDocument.md](ProductRequirementsDocument.md).
## Development
```bash
uv sync --dev
uv run pytest
uv run ruff check src tests
uv run sbom-nexus serve --reload
```
The default API listens on `http://127.0.0.1:8010`. Its data location can be
changed with `SBOM_NEXUS_DATABASE_PATH`.
## Initial API surface
- `GET /state/health`
- `PUT /repositories/{repo_slug}`
- `GET /sbom/catch-up?limit=3`
- `POST /sbom/{repo_slug}/ingest`
- State Hub-compatible `/sbom/` snapshot, entry, repository, and licence routes
See [docs/state-hub-sbom-extraction-review.md](docs/state-hub-sbom-extraction-review.md)
for the extraction inventory and cutover dispositions.

33
SCOPE.md Normal file
View file

@ -0,0 +1,33 @@
# Scope
## One-liner
SBOM Nexus captures, preserves, evaluates, and operationally refreshes
software-bill-of-materials evidence for managed repositories.
## In scope
- repository-source detection and parsing;
- immutable SBOM snapshots, entries, provenance, and history;
- latest-inventory and snapshot query APIs;
- licence inventory and qualified copyleft triage signals;
- freshness evaluation and bounded oldest-first catch-up;
- State Hub-compatible SBOM routes during migration;
- historical State Hub SBOM import and reconciliation;
- operator CLI, service packaging, tests, and operational documentation.
## Out of scope
- repository lifecycle and checkout control (Repo Manager);
- recurring scheduling (Activity Core);
- general work coordination (State Hub/hub-core/issue-core);
- artifact blob storage (Artifact Store);
- authentication policy ownership (Flex Auth);
- legal advice or a general vulnerability intelligence database;
- non-SBOM State Hub extraction.
## Current state
The repo is in its initial extraction milestone under `CUST-WP-0062` and
`SBOM-WP-0001`. State Hub compatibility and local SQLite operation are the first
vertical slice; PostgreSQL migration and production cutover remain gated work.

16
WORK-RECORDS.md Normal file
View file

@ -0,0 +1,16 @@
# Work Records — sbom-nexus
> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record
> stage 3). Do not edit by hand — edit the source file/block listed for
> each record and re-run fix-consistency to refresh this index. Archived
> workplans are omitted; closed decisions/intakes/engagements stay listed
> so recently-resolved work is still visible. [auto]
| Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- |
| workplan | SBOM-WP-0001 | active | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |
| task | SBOM-WP-0001-T01 | done | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |
| task | SBOM-WP-0001-T02 | done | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |
| task | SBOM-WP-0001-T03 | done | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |
| task | SBOM-WP-0001-T04 | done | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |
| task | SBOM-WP-0001-T05 | todo | — | workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md |

65
docs/operator-guide.md Normal file
View file

@ -0,0 +1,65 @@
# Operator guide
## Run locally
```bash
make install
make run
```
The API defaults to `127.0.0.1:8010` and `./sbom-nexus.db`. Set
`SBOM_NEXUS_DATABASE_PATH` to an explicit durable location for non-development
use.
## Register and ingest a repository
```bash
curl -X PUT http://127.0.0.1:8010/repositories/example \
-H 'Content-Type: application/json' \
-d '{"checkout_path":"/srv/repos/example","active":true}'
curl -X POST http://127.0.0.1:8010/sbom/example/ingest
```
The ingest response is terminal: `ingested`, or `skipped` with one of
`no-checkout`, `no-manifest`, or `ingest-error`. A skip advances queue fairness
but does not advance `last_success_at`.
## Inspect catch-up
```bash
curl -s 'http://127.0.0.1:8010/sbom/catch-up?limit=3' | python3 -m json.tool
```
The default stale threshold is 30 days. Override it globally with
`SBOM_NEXUS_STALE_DAYS` or per query with `stale_days` during controlled
operation.
## Import State Hub history
Run a read-only preview first:
```bash
uv run python scripts/import_state_hub.py --dry-run
```
Then run against a backed-up Nexus database:
```bash
uv run python scripts/import_state_hub.py \
--source-url http://127.0.0.1:8000 \
--target-url http://127.0.0.1:8010
```
Imports are idempotent on the State Hub snapshot UUID. Before cutover, compare
the source/target repository, snapshot, and entry counts described in the
extraction review. The current script reports counts but is not yet the complete
production reconciliation gate.
## Current production limitations
- The extraction store is SQLite and intended for local/single-node operation.
- Authentication and authorization are not yet integrated.
- Structured operational metrics, PostgreSQL migrations, backup/restore proof,
and retention policy are required before authority cutover.
- State Hub and Repo Manager callers have not yet been retargeted.

View file

@ -0,0 +1,156 @@
# State Hub SBOM extraction review
**Reviewed:** 2026-08-22
**Coordination:** `CUST-WP-0062-T01`, `STATE-WP-0079`, `RMGR-WP-0008`,
`ACTIVITY-WP-0030`
## Outcome
The SBOM capability is a coherent product embedded in State Hub, with a newer
derived-scanner copy in Repo Manager and an already-prepared Activity Core
consumer contract. The target ownership is:
- **SBOM Nexus:** scanning, ingest, snapshots, entries, history, reports,
freshness, catch-up, import/export;
- **Repo Manager:** repository identity, active status, and checkout/path
representation; invoke Nexus rather than retain a second scanner;
- **State Hub:** temporary compatibility façade and repository freshness
projection only;
- **Activity Core:** recurrence and the at-most-N side effect;
- **dashboard/MCP/CLI callers:** retarget to Nexus or the temporary façade.
This corrects a timing overlap in the retirement work: `RMGR-WP-0008` built a
good repository-derived scanner before `CUST-WP-0062` established the dedicated
Nexus. That scanner is the best extraction source, but Repo Manager is not the
durable SBOM product owner.
## Current behavior
State Hub stores an immutable snapshot container and entries keyed to a managed
repository. Manual ingest creates a snapshot, stores entries, and updates
`ManagedRepo.last_sbom_at`. Reads return snapshot history, snapshot detail,
latest entries per repository, a repository view, and a licence grouping with a
simple copyleft-family signal.
Its ingest script recursively recognizes eight source families and posts the
merged result to the API. MCP, the `statehub` CLI, Make targets, onboarding,
repository health, summary caches, and dashboard pages depend on these routes.
Repo Manager subsequently improved the derivation step with source-path and
SHA-256 provenance, Git revision, canonical generation time, structured parser
errors, and direct-production copyleft findings. Its snapshot schema is
`repo-manager.sbom-snapshot.v1`.
Activity Core already contains a disabled/test-double consumer for the desired
Nexus endpoint. It expects `GET /sbom/catch-up?limit=N`, defaults to 3, bounds N
to 1..25, and requires the Nexus to return an ordered `repos` list plus fleet
counts. The old weekly task-flood definition is disabled.
## Artifact and caller disposition
| Existing surface | Location | Disposition | Target/action |
| --- | --- | --- | --- |
| `SBOMSnapshot` model/table | `state-hub/api/models/sbom_snapshot.py`, migrations | move/import | Nexus snapshot store; retain legacy id/timestamp/source provenance |
| `SBOMEntry` model/table and ecosystem enum | `state-hub/api/models/sbom_entry.py`, migrations | move/import | Nexus entries; retain compatibility field names |
| manual ingest | `POST /sbom/ingest/` | move + proxy | Implement compat route in Nexus; State Hub proxies during cutover |
| snapshot list/detail | `GET /sbom/snapshots/*` | move + proxy | Nexus owns history and response contract |
| latest entry filters | `GET /sbom/` | move + proxy | Nexus owns current projections |
| repository view | `GET /sbom/{slug}` | move + proxy | Nexus owns inventory; join/sync minimal repo projection |
| licence report | `GET /sbom/report/licences/` | move + proxy | Nexus owns qualified signal and later SPDX policy integration |
| parser/detection script | `state-hub/scripts/ingest_sbom.py` | move, then retire | Extract improved Repo Manager variant into independent Nexus scanner |
| derived scanner | `repo-manager/src/repo_manager/sbom.py` | move/replace | Make Nexus the implementation; Repo Manager becomes caller/adapter |
| scanner CLI | `rmgr sbom scan`, `licence-report` | retarget | Preserve CLI UX by calling Nexus/local Nexus library during transition |
| State Hub Make target | `make ingest-sbom` | retarget | Invoke Nexus CLI/API; retain temporary alias with deprecation notice |
| `statehub ingest-sbom` | `custodian_cli.py` | retarget | Invoke Nexus, not State Hub-local script |
| tool capture script/prompt | `scripts/capture_sbom_tools.py`, `prompts/sbom-capture-agent.md` | move later | Nexus operator workflow; keep reviewed manifest as repository authority |
| MCP resources | `state://sbom/*` | retarget/proxy | Fetch Nexus; later publish Nexus-native MCP if useful |
| MCP ingest/report tools | `ingest_sbom_tool`, `get_licence_report` | retarget | Call Nexus contracts |
| dashboard SBOM page/data loader | `dashboard/src/sbom.md`, `data/sbom.json.py` | retarget | Read Nexus or façade; no new dashboard in extraction release |
| repo/dashboard coverage cards | repos/overview pages | projection | Consume Nexus freshness projection |
| `ManagedRepo.last_sbom_at`, `sbom_source` | State Hub repo model | strangle/projection | Keep compatible fields, written/synchronized from Nexus during cutover |
| summary cache revision | `api/services/summary_cache.py` | adapt | Revision from Nexus projection/event, not local SBOM tables |
| State summary licence scan | `api/routers/state.py` | adapt | Consume Nexus report/projection |
| DoI criterion C8 | `api/doi_engine.py`, `policies/repo-doi.md` | retarget | Query precise Nexus attempt/success state |
| edge cache `/sbom/` | `api/edge/read_cache.py` | retire/retarget | Cache façade only while compatibility route exists |
| repo registration optional ingest | `scripts/register_project.sh` and onboarding docs | retarget | Register projection then invoke Nexus ingest |
| repository synchronization view | `dashboard/src/repo-sync.md` | retarget | Use Nexus status |
| Activity Core bulk status resolver | old `repo_sbom_status` | retire | Replace with one Nexus catch-up query |
| daily bounded consumer | `activity_core/context_resolvers/sbom_nexus.py` | retain/activate later | Contract is accepted input for Nexus implementation |
| weekly task flood | `weekly-sbom-staleness` | retire | Keep disabled; no one-task-per-stale-repo behavior |
| historical State Hub rows | 22 snapshots/18 repos in RMGR evidence | import + retain | Import history; reconcile counts; delete only after retention gate |
## Extracted source coverage
The first Nexus scanner preserves the proven coverage common to State Hub and
Repo Manager:
| Source | Ecosystem | Important behavior |
| --- | --- | --- |
| `uv.lock` | Python | all locked packages |
| `requirements.txt` | Python | declared lines marked direct |
| `package-lock.json` | Node | package metadata, dev/direct hints, licence when present |
| `yarn.lock` | Node | locked package/version extraction |
| `Cargo.lock` | Rust | all locked packages |
| `go.sum` + `go.mod` | Go | deduplicated modules; direct hint from `go.mod` |
| `.terraform.lock.hcl` | Terraform | provider/version entries |
| `ansible/requirements.y[a]ml` | Ansible | collections and roles under `ansible` directories |
| root `sbom-tools.yaml` | Tool/declared | reviewed escape hatch including licence and direct/dev flags |
Excluded directories remain `.git`, VCS metadata, virtual environments,
`node_modules`, caches, distribution/build outputs, Rust `target`, tox, and
nox environments.
## Contract differences and improvements
1. Nexus records `last_attempt_at` separately from `last_success_at`. State
Hub's single `last_sbom_at` cannot truthfully express a skipped scan.
2. Every attempt has a terminal status. `no-checkout` and `no-manifest` are
successful control-loop outcomes but not successful inventories.
3. Parser errors are stored with the snapshot; they are not silently reduced to
empty entries.
4. Source paths, digests, and Git revision are retained from the Repo Manager
implementation.
5. Copyleft substring matching remains compatibility triage. It is explicitly
not an SPDX expression evaluator or legal conclusion.
6. Catch-up uses last attempt for queue fairness and last success for inventory
truth. Consumers can display both rather than infer one from the other.
## Historical migration requirements
The migration must enumerate State Hub snapshots by repository and oldest
first, fetch each snapshot detail, upsert the repository projection, and insert
the snapshot with its legacy UUID and timestamp. It must be idempotent on the
legacy UUID and compare:
- repository count;
- snapshot count per repository and total;
- entry count per snapshot and total;
- earliest/latest timestamps;
- sampled current-repository views;
- licence group and direct-production copyleft counts.
Fresh repository scans do not replace imported history. They become later
snapshots based on current source evidence.
## Cutover gates
- Nexus production store, migrations, backup, and restore evidence exist.
- Historical import reconciles before State Hub deletion is considered.
- State Hub compatibility tests pass through the façade.
- Repo Manager no longer presents its scanner as independent SBOM authority.
- Dashboard, MCP, CLI, DoI, summary, and onboarding callers are inventoried and
retargeted.
- Activity Core's ingest side effect is integrated but remains bounded by the
resolver's effective limit.
- Two production daily fires record at most N terminal outcomes and zero spawned
per-repository tasks.
- Weekly fleet task-flood behavior remains disabled.
## Review conclusion
Extraction is feasible without changing supported ecosystems. The principal
risk is not parser migration; it is authority and history cutover across three
temporarily overlapping implementations. The first Nexus slice therefore pins
the compatibility and catch-up contracts, while production ownership remains a
separate gated task requiring State Hub and Repo Manager adapter changes.

39
pyproject.toml Normal file
View file

@ -0,0 +1,39 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sbom-nexus"
version = "0.1.0"
description = "SBOM capture, history, evaluation, and bounded catch-up service"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"pydantic>=2.10",
"pyyaml>=6.0",
"uvicorn>=0.34",
]
[dependency-groups]
dev = [
"httpx>=0.28",
"pytest>=7.4",
"ruff>=0.9",
]
[project.scripts]
sbom-nexus = "sbom_nexus.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/sbom_nexus"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

5
registry/README.md Normal file
View file

@ -0,0 +1,5 @@
# SBOM Nexus capability registry
This directory publishes the reusable product capabilities owned by SBOM Nexus.
Entries are descriptive discovery records; the HTTP/OpenAPI contract remains the
runtime interface.

View file

@ -0,0 +1,19 @@
---
id: capability.infotech.sbom-management
type: capability
name: SBOM management
repo: sbom-nexus
domain: infotech
status: emerging
interfaces:
- http
- cli
---
# SBOM management
Capture repository dependency evidence, preserve immutable SBOM snapshot
history, query current inventory and licence signals, and select a deterministic
bounded fleet catch-up queue.
The extraction interface is documented in `ProductRequirementsDocument.md`.

View file

@ -0,0 +1,6 @@
schema: reuse-surface.capability-index.v1
repo: sbom-nexus
capabilities:
- id: capability.infotech.sbom-management
path: registry/capabilities/capability.infotech.sbom-management.md
status: emerging

125
scripts/import_state_hub.py Normal file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Idempotently import historical State Hub SBOM snapshots into SBOM Nexus."""
from __future__ import annotations
import argparse
import json
import socket
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any
def request_json(
base_url: str,
path: str,
*,
method: str = "GET",
body: dict[str, Any] | None = None,
) -> Any:
payload = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
data=payload,
method=method,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
def checkout_path(repo: dict[str, Any]) -> str | None:
host_paths = repo.get("host_paths") or {}
return host_paths.get(socket.gethostname()) or repo.get("local_path")
def import_history(source_url: str, target_url: str, *, dry_run: bool) -> dict[str, Any]:
repositories = request_json(source_url, "/repos/")
repo_by_id = {str(repo["id"]): repo for repo in repositories}
snapshots = request_json(source_url, "/sbom/snapshots/")
snapshots.sort(key=lambda item: (item["snapshot_at"], item["id"]))
results = Counter()
by_repo = Counter()
source_entries = 0
for snapshot in snapshots:
repo = repo_by_id.get(str(snapshot["repo_id"]))
if repo is None:
results["missing_repo"] += 1
continue
repo_slug = repo["slug"]
detail = request_json(source_url, f"/sbom/snapshots/{snapshot['id']}")
entries = [
{
"package_name": entry["package_name"],
"package_version": entry.get("package_version"),
"ecosystem": entry["ecosystem"],
"license_spdx": entry.get("license_spdx"),
"is_direct": entry.get("is_direct", True),
"is_dev": entry.get("is_dev", False),
"source_path": entry.get("source_path"),
}
for entry in detail.get("entries", [])
]
source_entries += len(entries)
if dry_run:
results["would_import"] += 1
by_repo[repo_slug] += 1
continue
request_json(
target_url,
f"/repositories/{urllib.parse.quote(repo_slug, safe='')}",
method="PUT",
body={
"checkout_path": checkout_path(repo),
"active": repo.get("status", "active") == "active",
},
)
imported = request_json(
target_url,
"/sbom/import/",
method="POST",
body={
"repo_slug": repo_slug,
"legacy_id": str(snapshot["id"]),
"snapshot_at": snapshot["snapshot_at"],
"source": f"state-hub:{snapshot.get('source') or 'manual'}",
"entries": entries,
},
)
results["imported" if imported["imported"] else "already_present"] += 1
by_repo[repo_slug] += 1
target_snapshots = [] if dry_run else request_json(target_url, "/sbom/snapshots/")
return {
"ok": results["missing_repo"] == 0,
"dry_run": dry_run,
"source_snapshot_count": len(snapshots),
"source_entry_count": source_entries,
"target_snapshot_count": len(target_snapshots) if not dry_run else None,
"results": dict(results),
"snapshots_by_repo": dict(sorted(by_repo.items())),
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-url", default="http://127.0.0.1:8000")
parser.add_argument("--target-url", default="http://127.0.0.1:8010")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
result = import_history(args.source_url, args.target_url, dry_run=args.dry_run)
except (urllib.error.URLError, json.JSONDecodeError) as exc:
raise SystemExit(f"Import failed: {exc}") from exc
print(json.dumps(result, indent=2, sort_keys=True))
if not result["ok"]:
raise SystemExit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,3 @@
"""SBOM Nexus product package."""
__version__ = "0.1.0"

288
src/sbom_nexus/api.py Normal file
View file

@ -0,0 +1,288 @@
"""FastAPI product and State Hub compatibility surface."""
from __future__ import annotations
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sbom_nexus.scanner import VALID_ECOSYSTEMS, scan_repository
from sbom_nexus.storage import Store
DEFAULT_DATABASE_PATH = os.getenv("SBOM_NEXUS_DATABASE_PATH", "sbom-nexus.db")
DEFAULT_STALE_DAYS = int(os.getenv("SBOM_NEXUS_STALE_DAYS", "30"))
class RepositoryUpsert(BaseModel):
checkout_path: str | None = None
active: bool = True
last_sbom_at: datetime | None = None
last_attempt_at: datetime | None = None
last_success_at: datetime | None = None
class EntryCreate(BaseModel):
package_name: str = Field(min_length=1, max_length=300)
package_version: str | None = Field(default=None, max_length=100)
ecosystem: str
license_spdx: str | None = Field(default=None, max_length=100)
is_direct: bool = True
is_dev: bool = False
source_path: str | None = None
class LegacyIngest(BaseModel):
repo_slug: str = Field(min_length=1)
entries: list[EntryCreate]
class HistoricalImport(BaseModel):
repo_slug: str = Field(min_length=1)
legacy_id: str = Field(min_length=1)
snapshot_at: datetime
source: str = "state-hub-import"
source_revision: str | None = None
entries: list[EntryCreate]
errors: list[dict[str, Any]] = Field(default_factory=list)
class SkipRequest(BaseModel):
reason: Literal["no-checkout", "no-manifest", "ingest-error"]
detail: str | None = None
def _store(request: Request) -> Store:
return request.app.state.store
def _not_found(repo_slug: str) -> HTTPException:
return HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
def _validate_entries(entries: list[EntryCreate]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for entry in entries:
if entry.ecosystem not in VALID_ECOSYSTEMS:
raise HTTPException(
status_code=422,
detail=f"Unsupported ecosystem '{entry.ecosystem}'",
)
result.append(entry.model_dump())
return result
def create_app(database_path: str | Path | None = None) -> FastAPI:
application = FastAPI(
title="SBOM Nexus",
version="0.1.0",
description="SBOM capture, history, evaluation, and bounded catch-up service",
)
application.state.store = Store(database_path or DEFAULT_DATABASE_PATH)
application.state.store.init_schema()
@application.get("/state/health")
def health(request: Request) -> dict[str, str]:
_store(request).list_repositories()
return {"status": "ok", "store": "connected"}
@application.put("/repositories/{repo_slug}")
def upsert_repository(
repo_slug: str, body: RepositoryUpsert, request: Request
) -> dict[str, Any]:
legacy_time = body.last_sbom_at
return _store(request).upsert_repository(
repo_slug,
checkout_path=body.checkout_path,
active=body.active,
last_attempt_at=body.last_attempt_at or legacy_time,
last_success_at=body.last_success_at or legacy_time,
)
@application.get("/repositories/")
def list_repositories(request: Request) -> list[dict[str, Any]]:
return _store(request).list_repositories()
@application.get("/sbom/catch-up")
def catch_up(
request: Request,
limit: int = Query(default=3, ge=1, le=25),
stale_days: int = Query(default=DEFAULT_STALE_DAYS, ge=1, le=3650),
) -> dict[str, Any]:
return _store(request).catch_up(limit=limit, stale_days=stale_days)
@application.post("/sbom/ingest/")
def ingest_legacy(body: LegacyIngest, request: Request) -> dict[str, Any]:
store = _store(request)
if store.get_repository(body.repo_slug) is None:
raise _not_found(body.repo_slug)
snapshot, _created = store.record_snapshot(
body.repo_slug,
entries=_validate_entries(body.entries),
status="ingested",
source="manual",
)
return {
"repo_slug": body.repo_slug,
"snapshot_id": snapshot["id"],
"ingested": snapshot["entry_count"],
"snapshot_at": snapshot["snapshot_at"],
"status": snapshot["status"],
}
@application.post("/sbom/import/")
def import_historical(body: HistoricalImport, request: Request) -> dict[str, Any]:
store = _store(request)
if store.get_repository(body.repo_slug) is None:
store.upsert_repository(body.repo_slug)
snapshot, created = store.record_snapshot(
body.repo_slug,
entries=_validate_entries(body.entries),
status="imported",
source=body.source,
snapshot_at=body.snapshot_at,
source_revision=body.source_revision,
errors=body.errors,
snapshot_id=body.legacy_id,
legacy_id=body.legacy_id,
)
return {
"repo_slug": body.repo_slug,
"snapshot_id": snapshot["id"],
"imported": created,
"entry_count": snapshot["entry_count"],
"snapshot_at": snapshot["snapshot_at"],
}
@application.get("/sbom/snapshots/")
def list_snapshots(
request: Request, repo_slug: str | None = Query(default=None)
) -> list[dict[str, Any]]:
store = _store(request)
if repo_slug and store.get_repository(repo_slug) is None:
raise _not_found(repo_slug)
return store.list_snapshots(repo_slug)
@application.get("/sbom/snapshots/{snapshot_id}")
def get_snapshot(snapshot_id: str, request: Request) -> dict[str, Any]:
snapshot = _store(request).get_snapshot(snapshot_id)
if snapshot is None:
raise HTTPException(
status_code=404, detail=f"Snapshot '{snapshot_id}' not found"
)
return snapshot
@application.get("/sbom/report/licences/")
def licence_report(request: Request) -> dict[str, Any]:
return _store(request).licence_report()
@application.get("/sbom/")
def list_entries(
request: Request,
repo_slug: str | None = Query(default=None),
ecosystem: str | None = Query(default=None),
license_spdx: str | None = Query(default=None),
is_direct: bool | None = Query(default=None),
is_dev: bool | None = Query(default=None),
) -> list[dict[str, Any]]:
store = _store(request)
if repo_slug and store.get_repository(repo_slug) is None:
raise _not_found(repo_slug)
return store.latest_entries(
repo_slug=repo_slug,
ecosystem=ecosystem,
license_spdx=license_spdx,
is_direct=is_direct,
is_dev=is_dev,
)
@application.post("/sbom/{repo_slug}/ingest")
def ingest_repository(repo_slug: str, request: Request) -> dict[str, Any]:
store = _store(request)
repo = store.get_repository(repo_slug)
if repo is None:
raise _not_found(repo_slug)
checkout_path = repo["checkout_path"]
if not checkout_path or not Path(checkout_path).is_dir():
return _record_skip(store, repo_slug, "no-checkout")
scan = scan_repository(Path(checkout_path), slug=repo_slug)
if not scan["sources"] and not scan["errors"]:
return _record_skip(store, repo_slug, "no-manifest")
if scan["errors"]:
snapshot, _created = store.record_snapshot(
repo_slug,
entries=scan["entries"],
status="ingest-error",
source="ingest-error",
source_revision=scan["source_revision"],
sources=scan["sources"],
errors=scan["errors"],
)
return {
"repo_slug": repo_slug,
"snapshot_id": snapshot["id"],
"status": "skipped",
"reason": "ingest-error",
"entry_count": snapshot["entry_count"],
"errors": scan["errors"],
"snapshot_at": snapshot["snapshot_at"],
}
snapshot, _created = store.record_snapshot(
repo_slug,
entries=scan["entries"],
status="ingested",
source="repository-scan",
source_revision=scan["source_revision"],
sources=scan["sources"],
)
return {
"repo_slug": repo_slug,
"snapshot_id": snapshot["id"],
"status": "ingested",
"entry_count": snapshot["entry_count"],
"snapshot_at": snapshot["snapshot_at"],
"source_revision": snapshot["source_revision"],
}
@application.post("/sbom/{repo_slug}/skip")
def skip_repository(
repo_slug: str, body: SkipRequest, request: Request
) -> dict[str, Any]:
store = _store(request)
if store.get_repository(repo_slug) is None:
raise _not_found(repo_slug)
return _record_skip(store, repo_slug, body.reason, body.detail)
@application.get("/sbom/{repo_slug}")
def get_repo_sbom(repo_slug: str, request: Request) -> dict[str, Any]:
view = _store(request).repository_view(repo_slug)
if view is None:
raise _not_found(repo_slug)
return view
return application
def _record_skip(
store: Store, repo_slug: str, reason: str, detail: str | None = None
) -> dict[str, Any]:
errors = [{"reason": reason, "detail": detail}] if detail else []
snapshot, _created = store.record_snapshot(
repo_slug,
entries=[],
status=reason,
source=reason,
errors=errors,
)
return {
"repo_slug": repo_slug,
"snapshot_id": snapshot["id"],
"status": "skipped",
"reason": reason,
"entry_count": 0,
"snapshot_at": snapshot["snapshot_at"],
}

67
src/sbom_nexus/cli.py Normal file
View file

@ -0,0 +1,67 @@
"""Operator CLI for SBOM Nexus."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from sbom_nexus.scanner import scan_repository
def _serve(args: argparse.Namespace) -> None:
if args.database:
os.environ["SBOM_NEXUS_DATABASE_PATH"] = str(Path(args.database).resolve())
import uvicorn
uvicorn.run(
"sbom_nexus.api:create_app",
host=args.host,
port=args.port,
reload=args.reload,
factory=True,
)
def _scan(args: argparse.Namespace) -> None:
result = scan_repository(Path(args.path), slug=args.slug)
rendered = json.dumps(result, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
if output.exists() and not args.force:
raise SystemExit(f"Refusing to overwrite {output}; pass --force")
output.write_text(rendered + "\n", encoding="utf-8")
else:
print(rendered)
if not result["ok"]:
raise SystemExit(1)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="sbom-nexus")
subparsers = parser.add_subparsers(dest="command", required=True)
serve = subparsers.add_parser("serve", help="Run the SBOM Nexus HTTP API")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8010)
serve.add_argument("--database", help="SQLite database path")
serve.add_argument("--reload", action="store_true")
serve.set_defaults(handler=_serve)
scan = subparsers.add_parser("scan", help="Derive a snapshot from repository sources")
scan.add_argument("path", nargs="?", default=".")
scan.add_argument("--slug")
scan.add_argument("--output")
scan.add_argument("--force", action="store_true")
scan.set_defaults(handler=_scan)
return parser
def main() -> None:
args = build_parser().parse_args()
args.handler(args)
if __name__ == "__main__":
main()

364
src/sbom_nexus/scanner.py Normal file
View file

@ -0,0 +1,364 @@
"""Repository-derived SBOM scanning and licence triage.
The parser set is extracted from State Hub's ``scripts/ingest_sbom.py`` and the
provenance-aware receiving implementation in Repo Manager (RMGR-WP-0008).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
import tomllib
from collections import Counter
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import yaml
SBOM_SCHEMA = "sbom-nexus.snapshot.v1"
COPYLEFT_MARKERS = frozenset({"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"})
VALID_ECOSYSTEMS = frozenset(
{"python", "node", "rust", "go", "java", "terraform", "ansible", "tool", "other"}
)
SKIP_DIRECTORIES = frozenset(
{
".git",
".hg",
".svn",
".venv",
"venv",
".env",
"node_modules",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
"build",
".build",
"target",
".tox",
".nox",
}
)
Entry = dict[str, Any]
Parser = Callable[[Path], list[Entry]]
def _entry(
name: str,
version: str | None,
ecosystem: str,
*,
license_spdx: str | None = None,
is_direct: bool = False,
is_dev: bool = False,
) -> Entry:
return {
"package_name": name,
"package_version": version,
"ecosystem": ecosystem,
"license_spdx": license_spdx,
"is_direct": is_direct,
"is_dev": is_dev,
}
def _parse_toml_packages(path: Path, ecosystem: str) -> list[Entry]:
data = tomllib.loads(path.read_text(encoding="utf-8"))
return [
_entry(str(item["name"]), str(item["version"]) if item.get("version") else None, ecosystem)
for item in data.get("package", [])
if isinstance(item, dict) and item.get("name")
]
def parse_uv_lock(path: Path) -> list[Entry]:
return _parse_toml_packages(path, "python")
def parse_cargo_lock(path: Path) -> list[Entry]:
return _parse_toml_packages(path, "rust")
def parse_requirements(path: Path) -> list[Entry]:
entries: list[Entry] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith(("#", "-")):
continue
match = re.match(r"^([A-Za-z0-9_.-]+)(?:[>=<!~^]+([^\s;]+))?", line)
if match:
entries.append(_entry(match.group(1), match.group(2), "python", is_direct=True))
return entries
def parse_package_lock(path: Path) -> list[Entry]:
data = json.loads(path.read_text(encoding="utf-8"))
entries: list[Entry] = []
for package_path, item in (data.get("packages") or {}).items():
if not package_path or not isinstance(item, dict):
continue
name = item.get("name") or package_path.rsplit("node_modules/", 1)[-1]
entries.append(
_entry(
str(name),
str(item["version"]) if item.get("version") else None,
"node",
license_spdx=str(item["license"]) if item.get("license") else None,
is_direct=not bool(item.get("indirect", False)),
is_dev=bool(item.get("dev", False)),
)
)
return entries
def parse_yarn_lock(path: Path) -> list[Entry]:
entries: list[Entry] = []
names: list[str] = []
for raw in path.read_text(encoding="utf-8").splitlines():
stripped = raw.strip()
if raw and not raw.startswith((" ", "\t")) and stripped.endswith(":"):
names = []
for specifier in stripped.rstrip(":").split(","):
match = re.match(r'"?((?:@[^/" ]+/)?[^@" ]+)@', specifier.strip())
if match:
names.append(match.group(1))
elif stripped.startswith("version ") and names:
version_match = re.search(r'"([^"]+)"', stripped)
version = version_match.group(1) if version_match else None
entries.extend(_entry(name, version, "node") for name in names)
names = []
return entries
def _go_direct_modules(directory: Path) -> set[str]:
path = directory / "go.mod"
if not path.is_file():
return set()
direct: set[str] = set()
in_block = False
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line == "require (":
in_block = True
continue
if in_block and line == ")":
in_block = False
continue
if in_block:
candidate = line
elif line.startswith("require "):
candidate = line.removeprefix("require ")
else:
candidate = ""
if candidate and "// indirect" not in candidate:
direct.add(candidate.split()[0])
return direct
def parse_go_sum(path: Path) -> list[Entry]:
direct = _go_direct_modules(path.parent)
seen: set[tuple[str, str]] = set()
entries: list[Entry] = []
for raw in path.read_text(encoding="utf-8").splitlines():
parts = raw.split()
if len(parts) < 2 or parts[1].endswith("/go.mod"):
continue
key = (parts[0], parts[1])
if key in seen:
continue
seen.add(key)
entries.append(_entry(key[0], key[1], "go", is_direct=parts[0] in direct))
return entries
def parse_terraform_lock(path: Path) -> list[Entry]:
entries: list[Entry] = []
provider: str | None = None
version: str | None = None
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
match = re.match(r'^provider\s+"([^"]+)"\s*\{', line)
if match:
provider, version = match.group(1), None
elif provider:
version_match = re.match(r'version\s*=\s*"([^"]+)"', line)
if version_match:
version = version_match.group(1)
elif line == "}":
entries.append(_entry(provider, version, "terraform", is_direct=True))
provider, version = None, None
return entries
def parse_ansible_requirements(path: Path) -> list[Entry]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return []
entries: list[Entry] = []
for kind in ("collections", "roles"):
for item in data.get(kind, []) or []:
if isinstance(item, str):
name, version = item, None
elif isinstance(item, dict):
name = item.get("name") or item.get("src")
version = str(item["version"]) if item.get("version") else None
else:
continue
if name:
entries.append(_entry(str(name), version, "ansible", is_direct=True))
return entries
def parse_tools_manifest(path: Path) -> list[Entry]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return []
entries: list[Entry] = []
for item in data.get("tools", []) or []:
if not isinstance(item, dict) or not item.get("name"):
continue
ecosystem = str(item.get("ecosystem") or "tool")
if ecosystem not in VALID_ECOSYSTEMS:
ecosystem = "tool"
raw_version = item.get("version")
version = str(raw_version) if raw_version not in {None, "unknown"} else None
entries.append(
_entry(
str(item["name"]),
version,
ecosystem,
license_spdx=str(item["license_spdx"]) if item.get("license_spdx") else None,
is_direct=bool(item.get("is_direct", True)),
is_dev=bool(item.get("is_dev", False)),
)
)
return entries
LOCKFILE_PARSERS: dict[str, Parser] = {
"uv.lock": parse_uv_lock,
"requirements.txt": parse_requirements,
"package-lock.json": parse_package_lock,
"yarn.lock": parse_yarn_lock,
"Cargo.lock": parse_cargo_lock,
".terraform.lock.hcl": parse_terraform_lock,
"go.sum": parse_go_sum,
}
def detect_sources(repo_root: Path) -> list[tuple[Path, Parser]]:
found: list[tuple[Path, Parser]] = []
seen: set[Path] = set()
for directory, directories, filenames in os.walk(repo_root):
directories[:] = sorted(name for name in directories if name not in SKIP_DIRECTORIES)
current = Path(directory)
for filename, parser in LOCKFILE_PARSERS.items():
if filename in filenames:
path = current / filename
found.append((path, parser))
seen.add(path)
if current.name == "ansible":
for filename in ("requirements.yml", "requirements.yaml"):
if filename in filenames:
path = current / filename
found.append((path, parse_ansible_requirements))
seen.add(path)
tools = repo_root / "sbom-tools.yaml"
if tools.is_file() and tools not in seen:
found.append((tools, parse_tools_manifest))
return sorted(found, key=lambda item: str(item[0]))
def is_copyleft(spdx: str | None) -> bool:
upper = (spdx or "").upper()
return any(marker in upper for marker in COPYLEFT_MARKERS)
def licence_report(entries: list[Entry]) -> dict[str, Any]:
counts = Counter(entry.get("license_spdx") for entry in entries)
groups = [
{"license_spdx": license_id, "count": count, "is_copyleft": is_copyleft(license_id)}
for license_id, count in sorted(counts.items(), key=lambda item: (-item[1], item[0] or ""))
]
risks = [
{
"package_name": entry["package_name"],
"package_version": entry.get("package_version"),
"license_spdx": entry.get("license_spdx"),
"source_path": entry.get("source_path"),
}
for entry in entries
if is_copyleft(entry.get("license_spdx"))
and entry.get("is_direct")
and not entry.get("is_dev")
]
return {
"groups": groups,
"copyleft_direct_prod": risks,
"copyleft_direct_count": len(risks),
}
def _head_sha(repo_root: Path) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
return result.stdout.strip() or None
def _utc_now_text() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def scan_repository(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]:
repo_root = repo_root.resolve()
sources: list[dict[str, Any]] = []
entries: list[Entry] = []
errors: list[dict[str, str]] = []
for path, parser in detect_sources(repo_root):
relative = str(path.relative_to(repo_root))
try:
parsed = parser(path)
except (
OSError,
ValueError,
TypeError,
json.JSONDecodeError,
tomllib.TOMLDecodeError,
yaml.YAMLError,
) as exc:
errors.append({"source_path": relative, "error": str(exc)})
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
sources.append({"path": relative, "sha256": digest, "entry_count": len(parsed)})
for item in parsed:
entries.append({**item, "source_path": relative})
return {
"schema": SBOM_SCHEMA,
"ok": not errors,
"repo_slug": slug or repo_root.name,
"repo_path": str(repo_root),
"source_revision": _head_sha(repo_root),
"generated_at": _utc_now_text(),
"authority": "detected lockfiles and reviewed sbom-tools.yaml",
"sources": sources,
"entry_count": len(entries),
"entries": entries,
"licence_report": licence_report(entries),
"errors": errors,
}

518
src/sbom_nexus/storage.py Normal file
View file

@ -0,0 +1,518 @@
"""SQLite persistence for the extraction slice.
The store keeps the product behavior independent from FastAPI. PostgreSQL and
managed migrations are an explicit production-cutover gate in SBOM-WP-0001-T05.
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from sbom_nexus.scanner import is_copyleft
SUCCESS_STATUSES = frozenset({"ingested", "imported"})
def utc_now() -> datetime:
return datetime.now(UTC)
def datetime_text(value: datetime | str | None) -> str | None:
if value is None:
return None
if isinstance(value, str):
parsed = parse_datetime(value)
else:
parsed = value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z")
def parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
class Store:
def __init__(self, database_path: str | Path) -> None:
self.database_path = str(database_path)
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def init_schema(self) -> None:
with self.connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
checkout_path TEXT,
active INTEGER NOT NULL DEFAULT 1,
last_attempt_at TEXT,
last_success_at TEXT,
last_status TEXT,
last_source TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
repo_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
snapshot_at TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
entry_count INTEGER NOT NULL,
source_revision TEXT,
sources_json TEXT NOT NULL DEFAULT '[]',
errors_json TEXT NOT NULL DEFAULT '[]',
legacy_id TEXT UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
repo_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
snapshot_id TEXT NOT NULL REFERENCES snapshots(id) ON DELETE RESTRICT,
package_name TEXT NOT NULL,
package_version TEXT,
ecosystem TEXT NOT NULL,
license_spdx TEXT,
is_direct INTEGER NOT NULL,
is_dev INTEGER NOT NULL,
source_path TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_snapshots_repo_time
ON snapshots(repo_id, snapshot_at DESC);
CREATE INDEX IF NOT EXISTS ix_entries_snapshot ON entries(snapshot_id);
CREATE INDEX IF NOT EXISTS ix_entries_repo ON entries(repo_id);
CREATE INDEX IF NOT EXISTS ix_entries_license ON entries(license_spdx);
"""
)
def upsert_repository(
self,
slug: str,
*,
checkout_path: str | None = None,
active: bool = True,
last_attempt_at: datetime | str | None = None,
last_success_at: datetime | str | None = None,
) -> dict[str, Any]:
now = datetime_text(utc_now())
attempt = datetime_text(last_attempt_at)
success = datetime_text(last_success_at)
with self.connect() as connection:
existing = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
if existing:
connection.execute(
"""
UPDATE repositories
SET checkout_path = ?, active = ?,
last_attempt_at = COALESCE(?, last_attempt_at),
last_success_at = COALESCE(?, last_success_at),
updated_at = ?
WHERE slug = ?
""",
(checkout_path, int(active), attempt, success, now, slug),
)
else:
connection.execute(
"""
INSERT INTO repositories (
id, slug, checkout_path, active, last_attempt_at,
last_success_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
slug,
checkout_path,
int(active),
attempt,
success,
now,
now,
),
)
row = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
return self._repository_dict(row)
def get_repository(self, slug: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
return self._repository_dict(row) if row else None
def list_repositories(self) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute("SELECT * FROM repositories ORDER BY slug").fetchall()
return [self._repository_dict(row) for row in rows]
def record_snapshot(
self,
repo_slug: str,
*,
entries: list[dict[str, Any]],
status: str,
source: str,
snapshot_at: datetime | str | None = None,
source_revision: str | None = None,
sources: list[dict[str, Any]] | None = None,
errors: list[dict[str, Any]] | None = None,
snapshot_id: str | None = None,
legacy_id: str | None = None,
) -> tuple[dict[str, Any], bool]:
timestamp = datetime_text(snapshot_at or utc_now())
created_at = datetime_text(utc_now())
with self.connect() as connection:
repo = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (repo_slug,)
).fetchone()
if repo is None:
raise KeyError(repo_slug)
if legacy_id:
existing = connection.execute(
"SELECT * FROM snapshots WHERE legacy_id = ?", (legacy_id,)
).fetchone()
if existing:
return self._snapshot_dict(existing), False
new_snapshot_id = snapshot_id or str(uuid.uuid4())
connection.execute(
"""
INSERT INTO snapshots (
id, repo_id, snapshot_at, source, status, entry_count,
source_revision, sources_json, errors_json, legacy_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
new_snapshot_id,
repo["id"],
timestamp,
source,
status,
len(entries),
source_revision,
json.dumps(sources or [], sort_keys=True),
json.dumps(errors or [], sort_keys=True),
legacy_id,
created_at,
),
)
for entry in entries:
connection.execute(
"""
INSERT INTO entries (
id, repo_id, snapshot_id, package_name, package_version,
ecosystem, license_spdx, is_direct, is_dev, source_path,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
repo["id"],
new_snapshot_id,
entry["package_name"],
entry.get("package_version"),
entry["ecosystem"],
entry.get("license_spdx"),
int(bool(entry.get("is_direct", True))),
int(bool(entry.get("is_dev", False))),
entry.get("source_path"),
created_at,
),
)
previous_attempt = repo["last_attempt_at"]
is_latest = previous_attempt is None or parse_datetime(timestamp) >= parse_datetime(
previous_attempt
)
if is_latest:
last_success = repo["last_success_at"]
if status in SUCCESS_STATUSES:
last_success = timestamp
connection.execute(
"""
UPDATE repositories
SET last_attempt_at = ?, last_success_at = ?, last_status = ?,
last_source = ?, updated_at = ?
WHERE id = ?
""",
(timestamp, last_success, status, source, created_at, repo["id"]),
)
row = connection.execute(
"SELECT * FROM snapshots WHERE id = ?", (new_snapshot_id,)
).fetchone()
return self._snapshot_dict(row), True
def list_snapshots(self, repo_slug: str | None = None) -> list[dict[str, Any]]:
query = """
SELECT s.*, r.slug AS repo_slug
FROM snapshots s JOIN repositories r ON r.id = s.repo_id
"""
params: tuple[Any, ...] = ()
if repo_slug:
query += " WHERE r.slug = ?"
params = (repo_slug,)
query += " ORDER BY s.snapshot_at DESC, s.created_at DESC"
with self.connect() as connection:
rows = connection.execute(query, params).fetchall()
return [self._snapshot_dict(row) for row in rows]
def get_snapshot(self, snapshot_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT s.*, r.slug AS repo_slug
FROM snapshots s JOIN repositories r ON r.id = s.repo_id
WHERE s.id = ?
""",
(snapshot_id,),
).fetchone()
if row is None:
return None
entries = connection.execute(
"SELECT * FROM entries WHERE snapshot_id = ? ORDER BY package_name",
(snapshot_id,),
).fetchall()
result = self._snapshot_dict(row)
result["entries"] = []
for entry in entries:
rendered = self._entry_dict(entry)
rendered["snapshot_at"] = result["snapshot_at"]
result["entries"].append(rendered)
return result
def latest_entries(
self,
*,
repo_slug: str | None = None,
ecosystem: str | None = None,
license_spdx: str | None = None,
is_direct: bool | None = None,
is_dev: bool | None = None,
) -> list[dict[str, Any]]:
latest = self._latest_snapshot_rows(repo_slug)
if not latest:
return []
snapshot_ids = [row["id"] for row in latest]
placeholders = ",".join("?" for _ in snapshot_ids)
query = f"""
SELECT e.*, r.slug AS repo_slug, s.snapshot_at AS snapshot_at
FROM entries e
JOIN repositories r ON r.id = e.repo_id
JOIN snapshots s ON s.id = e.snapshot_id
WHERE e.snapshot_id IN ({placeholders})
"""
params: list[Any] = list(snapshot_ids)
for field, value in (
("ecosystem", ecosystem),
("license_spdx", license_spdx),
("is_direct", None if is_direct is None else int(is_direct)),
("is_dev", None if is_dev is None else int(is_dev)),
):
if value is not None:
query += f" AND e.{field} = ?"
params.append(value)
query += " ORDER BY e.package_name, r.slug"
with self.connect() as connection:
rows = connection.execute(query, params).fetchall()
return [self._entry_dict(row) for row in rows]
def repository_view(self, repo_slug: str) -> dict[str, Any] | None:
repo = self.get_repository(repo_slug)
if repo is None:
return None
entries = self.latest_entries(repo_slug=repo_slug)
snapshots = self.list_snapshots(repo_slug)
return {
"repo_slug": repo_slug,
"last_sbom_at": repo["last_attempt_at"],
"last_attempt_at": repo["last_attempt_at"],
"last_success_at": repo["last_success_at"],
"last_status": repo["last_status"],
"entry_count": len(entries),
"snapshot_id": snapshots[0]["id"] if snapshots else None,
"entries": entries,
}
def licence_report(self) -> dict[str, Any]:
entries = self.latest_entries()
groups: dict[str | None, dict[str, Any]] = defaultdict(
lambda: {"count": 0, "repos": set()}
)
risks: list[dict[str, Any]] = []
for entry in entries:
license_id = entry.get("license_spdx")
groups[license_id]["count"] += 1
groups[license_id]["repos"].add(entry["repo_slug"])
if is_copyleft(license_id) and entry["is_direct"] and not entry["is_dev"]:
risks.append(
{
"repo_slug": entry["repo_slug"],
"package_name": entry["package_name"],
"package_version": entry.get("package_version"),
"license_spdx": license_id,
"source_path": entry.get("source_path"),
}
)
sorted_groups = sorted(groups.items(), key=lambda item: (-item[1]["count"], item[0] or ""))
return {
"groups": [
{
"license_spdx": license_id,
"count": data["count"],
"repos": sorted(data["repos"]),
"is_copyleft": is_copyleft(license_id),
}
for license_id, data in sorted_groups
],
"copyleft_direct_prod": risks,
"copyleft_direct_count": len(risks),
"signal_qualification": "substring triage; not legal advice or full SPDX evaluation",
}
def catch_up(
self,
*,
limit: int = 3,
stale_days: int = 30,
now: datetime | None = None,
) -> dict[str, Any]:
effective_limit = max(1, min(25, int(limit)))
current = now or utc_now()
cutoff = current - timedelta(days=stale_days)
repositories = [repo for repo in self.list_repositories() if repo["active"]]
never = [repo for repo in repositories if repo["last_attempt_at"] is None]
stale = [
repo
for repo in repositories
if repo["last_attempt_at"] is None
or parse_datetime(repo["last_attempt_at"]) < cutoff
]
stale.sort(
key=lambda repo: (
repo["last_attempt_at"] is not None,
repo["last_attempt_at"] or "",
repo["slug"],
)
)
selected = stale[:effective_limit]
return {
"repos": [self._catch_up_repository(repo, current) for repo in selected],
"selected_count": len(selected),
"stale_count": len(stale),
"never_count": len(never),
"total_count": len(repositories),
"limit": effective_limit,
"stale_after_days": stale_days,
"evaluated_at": datetime_text(current),
}
def _latest_snapshot_rows(self, repo_slug: str | None) -> list[sqlite3.Row]:
query = """
SELECT s.*, r.slug AS repo_slug
FROM snapshots s
JOIN repositories r ON r.id = s.repo_id
WHERE s.id = (
SELECT inner_s.id FROM snapshots inner_s
WHERE inner_s.repo_id = s.repo_id
ORDER BY inner_s.snapshot_at DESC, inner_s.created_at DESC
LIMIT 1
)
"""
params: tuple[Any, ...] = ()
if repo_slug:
query += " AND r.slug = ?"
params = (repo_slug,)
with self.connect() as connection:
return connection.execute(query, params).fetchall()
@staticmethod
def _repository_dict(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"slug": row["slug"],
"checkout_path": row["checkout_path"],
"active": bool(row["active"]),
"last_attempt_at": row["last_attempt_at"],
"last_success_at": row["last_success_at"],
"last_status": row["last_status"],
"last_source": row["last_source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@staticmethod
def _snapshot_dict(row: sqlite3.Row) -> dict[str, Any]:
result = {
"id": row["id"],
"repo_id": row["repo_id"],
"snapshot_at": row["snapshot_at"],
"source": row["source"],
"status": row["status"],
"entry_count": row["entry_count"],
"source_revision": row["source_revision"],
"sources": json.loads(row["sources_json"]),
"errors": json.loads(row["errors_json"]),
"legacy_id": row["legacy_id"],
"created_at": row["created_at"],
}
if "repo_slug" in row.keys():
result["repo_slug"] = row["repo_slug"]
if "snapshot_at" in row.keys():
result["snapshot_at"] = row["snapshot_at"]
return result
@staticmethod
def _entry_dict(row: sqlite3.Row) -> dict[str, Any]:
result = {
"id": row["id"],
"repo_id": row["repo_id"],
"snapshot_id": row["snapshot_id"],
"package_name": row["package_name"],
"package_version": row["package_version"],
"ecosystem": row["ecosystem"],
"license_spdx": row["license_spdx"],
"is_direct": bool(row["is_direct"]),
"is_dev": bool(row["is_dev"]),
"source_path": row["source_path"],
"created_at": row["created_at"],
}
if "repo_slug" in row.keys():
result["repo_slug"] = row["repo_slug"]
return result
@staticmethod
def _catch_up_repository(repo: dict[str, Any], now: datetime) -> dict[str, Any]:
attempt = repo["last_attempt_at"]
age_days = 9999 if attempt is None else max(0, (now - parse_datetime(attempt)).days)
checkout_path = repo["checkout_path"]
return {
"repo_slug": repo["slug"],
"last_sbom_at": attempt,
"last_attempt_at": attempt,
"last_success_at": repo["last_success_at"],
"sbom_age_days": age_days,
"has_sbom": repo["last_success_at"] is not None,
"checkout_available": bool(checkout_path and Path(checkout_path).is_dir()),
"last_status": repo["last_status"],
}

156
tests/test_api.py Normal file
View file

@ -0,0 +1,156 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from sbom_nexus.api import create_app
def client_for(tmp_path: Path) -> TestClient:
return TestClient(create_app(tmp_path / "nexus.db"))
def register(
client: TestClient,
slug: str,
*,
checkout_path: str | None = None,
last_sbom_at: datetime | None = None,
active: bool = True,
) -> None:
response = client.put(
f"/repositories/{slug}",
json={
"checkout_path": checkout_path,
"last_sbom_at": last_sbom_at.isoformat() if last_sbom_at else None,
"active": active,
},
)
assert response.status_code == 200
def test_health_and_legacy_ingest_query_and_licence_report(tmp_path: Path) -> None:
client = client_for(tmp_path)
assert client.get("/state/health").json() == {"status": "ok", "store": "connected"}
register(client, "demo")
response = client.post(
"/sbom/ingest/",
json={
"repo_slug": "demo",
"entries": [
{
"package_name": "safe",
"package_version": "1.0",
"ecosystem": "python",
"license_spdx": "MIT",
"is_direct": True,
"is_dev": False,
},
{
"package_name": "copyleft",
"package_version": "2.0",
"ecosystem": "python",
"license_spdx": "GPL-3.0-only",
"is_direct": True,
"is_dev": False,
},
],
},
)
assert response.status_code == 200
assert response.json()["ingested"] == 2
assert len(client.get("/sbom/snapshots/?repo_slug=demo").json()) == 1
assert client.get("/sbom/demo").json()["entry_count"] == 2
assert len(client.get("/sbom/?license_spdx=MIT").json()) == 1
report = client.get("/sbom/report/licences/").json()
assert report["copyleft_direct_count"] == 1
assert report["groups"][0]["repos"] == ["demo"]
def test_catch_up_is_bounded_null_first_then_oldest(tmp_path: Path) -> None:
client = client_for(tmp_path)
now = datetime.now(UTC)
register(client, "never-b")
register(client, "never-a")
register(client, "oldest", last_sbom_at=now - timedelta(days=90))
register(client, "old", last_sbom_at=now - timedelta(days=60))
register(client, "fresh", last_sbom_at=now - timedelta(days=2))
register(client, "inactive", active=False)
result = client.get("/sbom/catch-up?limit=3").json()
assert [repo["repo_slug"] for repo in result["repos"]] == [
"never-a",
"never-b",
"oldest",
]
assert result["selected_count"] == 3
assert result["stale_count"] == 4
assert result["never_count"] == 2
assert result["total_count"] == 5
def test_recorded_skip_advances_queue_without_claiming_success(tmp_path: Path) -> None:
client = client_for(tmp_path)
register(client, "a-no-checkout")
register(client, "b-never")
before = client.get("/sbom/catch-up?limit=1").json()
assert before["repos"][0]["repo_slug"] == "a-no-checkout"
outcome = client.post("/sbom/a-no-checkout/ingest").json()
assert outcome["status"] == "skipped"
assert outcome["reason"] == "no-checkout"
after = client.get("/sbom/catch-up?limit=1").json()
assert after["repos"][0]["repo_slug"] == "b-never"
repo = client.get("/sbom/a-no-checkout").json()
assert repo["last_attempt_at"] is not None
assert repo["last_success_at"] is None
def test_checkout_scan_records_provenance_and_success(tmp_path: Path) -> None:
repo = tmp_path / "checkout"
repo.mkdir()
(repo / "requirements.txt").write_text("fastapi==0.136.1\n", encoding="utf-8")
client = client_for(tmp_path)
register(client, "checkout", checkout_path=str(repo))
outcome = client.post("/sbom/checkout/ingest")
assert outcome.status_code == 200
assert outcome.json()["status"] == "ingested"
detail = client.get(f"/sbom/snapshots/{outcome.json()['snapshot_id']}").json()
assert detail["sources"][0]["path"] == "requirements.txt"
assert detail["sources"][0]["sha256"]
assert detail["entries"][0]["package_name"] == "fastapi"
assert detail["entries"][0]["snapshot_at"] == detail["snapshot_at"]
def test_historical_import_is_idempotent(tmp_path: Path) -> None:
client = client_for(tmp_path)
payload = {
"repo_slug": "legacy",
"legacy_id": "017f8f04-84c2-4e58-8be1-33a4cb43c43f",
"snapshot_at": "2026-01-02T03:04:05Z",
"source": "state-hub:manual",
"entries": [
{
"package_name": "legacy-package",
"package_version": "1.0",
"ecosystem": "other",
}
],
}
first = client.post("/sbom/import/", json=payload)
second = client.post("/sbom/import/", json=payload)
assert first.status_code == 200
assert first.json()["imported"] is True
assert second.json()["imported"] is False
assert len(client.get("/sbom/snapshots/?repo_slug=legacy").json()) == 1

114
tests/test_scanner.py Normal file
View file

@ -0,0 +1,114 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from sbom_nexus.scanner import detect_sources, scan_repository
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
def test_scan_derives_provenance_and_copyleft_report(tmp_path: Path) -> None:
repo = tmp_path / "example"
repo.mkdir()
_git(repo, "init")
_git(repo, "config", "user.email", "test@example.com")
_git(repo, "config", "user.name", "Test")
(repo / "uv.lock").write_text(
'version = 1\n[[package]]\nname = "pyyaml"\nversion = "6.0.2"\n',
encoding="utf-8",
)
(repo / "package-lock.json").write_text(
json.dumps(
{
"packages": {
"": {"name": "root", "version": "1.0.0"},
"node_modules/copyleft": {
"name": "copyleft",
"version": "2.0.0",
"license": "GPL-3.0-only",
},
"node_modules/dev-only": {
"name": "dev-only",
"version": "3.0.0",
"license": "AGPL-3.0-only",
"dev": True,
},
}
}
),
encoding="utf-8",
)
_git(repo, "add", ".")
_git(repo, "commit", "-m", "seed")
result = scan_repository(repo)
assert result["ok"] is True
assert result["schema"] == "sbom-nexus.snapshot.v1"
assert result["source_revision"]
assert result["generated_at"].endswith("Z")
assert result["entry_count"] == 3
assert result["sources"][0]["sha256"]
assert result["licence_report"]["copyleft_direct_count"] == 1
assert result["licence_report"]["copyleft_direct_prod"][0]["package_name"] == "copyleft"
def test_detection_covers_sources_and_skips_dependency_directories(tmp_path: Path) -> None:
(tmp_path / "go.sum").write_text("example.test/mod v1.2.3 h1:abc\n", encoding="utf-8")
terraform = tmp_path / "infra"
terraform.mkdir()
(terraform / ".terraform.lock.hcl").write_text(
'provider "registry.terraform.io/hashicorp/null" {\n version = "3.2.3"\n}\n',
encoding="utf-8",
)
ansible = tmp_path / "deploy" / "ansible"
ansible.mkdir(parents=True)
(ansible / "requirements.yml").write_text(
"collections:\n - community.general\n", encoding="utf-8"
)
ignored = tmp_path / "node_modules"
ignored.mkdir()
(ignored / "package-lock.json").write_text("{}", encoding="utf-8")
sources = {str(path.relative_to(tmp_path)) for path, _parser in detect_sources(tmp_path)}
assert sources == {
"deploy/ansible/requirements.yml",
"go.sum",
"infra/.terraform.lock.hcl",
}
def test_go_sum_marks_modules_declared_in_go_mod_as_direct(tmp_path: Path) -> None:
(tmp_path / "go.mod").write_text(
"module example.test/app\n\nrequire example.test/direct v1.2.3\n",
encoding="utf-8",
)
(tmp_path / "go.sum").write_text(
"example.test/direct v1.2.3 h1:abc\n"
"example.test/direct v1.2.3/go.mod h1:def\n"
"example.test/transitive v2.0.0 h1:ghi\n",
encoding="utf-8",
)
result = scan_repository(tmp_path)
assert result["ok"] is True
assert [(entry["package_name"], entry["is_direct"]) for entry in result["entries"]] == [
("example.test/direct", True),
("example.test/transitive", False),
]
def test_invalid_source_is_reported_without_false_success(tmp_path: Path) -> None:
(tmp_path / "uv.lock").write_text("not = [valid", encoding="utf-8")
result = scan_repository(tmp_path)
assert result["ok"] is False
assert result["entry_count"] == 0
assert result["errors"][0]["source_path"] == "uv.lock"

454
uv.lock generated Normal file
View file

@ -0,0 +1,454 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "ruff"
version = "0.16.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909 },
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201 },
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122 },
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162 },
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789 },
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949 },
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695 },
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741 },
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522 },
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182 },
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195 },
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821 },
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658 },
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071 },
{ url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687 },
{ url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657 },
{ url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579 },
]
[[package]]
name = "sbom-nexus"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "pydantic", specifier = ">=2.10" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "uvicorn", specifier = ">=0.34" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28" },
{ name = "pytest", specifier = ">=7.4" },
{ name = "ruff", specifier = ">=0.9" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969 },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
]
[[package]]
name = "uvicorn"
version = "0.52.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871 },
]

View file

@ -0,0 +1,83 @@
---
id: SBOM-WP-0001
type: workplan
title: "Bootstrap SBOM Nexus and extract the State Hub SBOM product"
domain: infotech
repo: sbom-nexus
status: active
owner: codex
topic_slug: infotech
created: "2026-08-22"
updated: "2026-08-22"
parent_workplan: CUST-WP-0062
related:
- CUST-WP-0062
- ACTIVITY-WP-0030
- STATE-WP-0079
- RMGR-WP-0008
---
# Bootstrap SBOM Nexus and extract the State Hub SBOM product
## Goal
Establish SBOM Nexus as a properly defined repository and deliver the first
working vertical slice of the State Hub extraction: source scanning, immutable
snapshot persistence, compatible queries and licence report, and bounded
oldest-stale catch-up behavior.
## Establish repository intent and conventions
```task
id: SBOM-WP-0001-T01
status: done
priority: high
```
Write the repository identity, intent, scope, classification, development
commands, and agent instructions; register and synchronize the repository.
## Define product requirements
```task
id: SBOM-WP-0001-T02
status: done
priority: high
```
Write an evidence-grounded PRD covering State Hub compatibility, snapshot
authority, bounded catch-up, skip semantics, migration, and production gates.
## Inventory and disposition the existing SBOM surface
```task
id: SBOM-WP-0001-T03
status: done
priority: high
```
Review State Hub, Repo Manager, Activity Core, dashboard, MCP, scripts, models,
and operational callers. Assign move, strangle, or retarget dispositions.
## Extract the first service slice
```task
id: SBOM-WP-0001-T04
status: done
priority: high
```
Implement the scanner, persistence, HTTP API, legacy compatibility routes,
licence report, catch-up selection, and terminal skip behavior with tests.
## Prepare cutover and historical migration
```task
id: SBOM-WP-0001-T05
status: todo
priority: high
```
Add PostgreSQL migrations and the idempotent historical import/reconciliation
runbook. Open and coordinate State Hub/Repo Manager/Activity Core child changes
before any production authority switch.