feat: prepare postgres sbom cutover

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 13:14:24 +02:00
parent cf7e3acb78
commit ba535e1f8f
26 changed files with 1573 additions and 411 deletions

View file

@ -44,6 +44,7 @@ uv run pytest
uv run ruff check src tests
uv run sbom-nexus scan .
uv run sbom-nexus serve --reload
make migrate
```
## Architecture constraints

View file

@ -1,4 +1,4 @@
.PHONY: install test lint check run scan
.PHONY: install test lint check run scan migrate migration-status
export UV_CACHE_DIR ?= /tmp/sbom-nexus-uv-cache
@ -18,3 +18,9 @@ run:
scan:
uv run sbom-nexus scan .
migrate:
uv run alembic upgrade head
migration-status:
uv run alembic current

View file

@ -182,9 +182,10 @@ Repository 1 ─── * Snapshot 1 ─── * Entry
- **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.
SQLite remains supported for extraction tests and single-node development.
Production uses PostgreSQL through the same transactional store and managed
Alembic migrations. Production cutover still requires backup/restore evidence
and explicit retention settings.
## 8. API contract

View file

@ -17,8 +17,9 @@ 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`.
The default API listens on `http://127.0.0.1:8010`. Local development uses
SQLite through `SBOM_NEXUS_DATABASE_PATH`; production uses
`SBOM_NEXUS_DATABASE_URL=postgresql+psycopg://...` and `make migrate`.
## Initial API surface

View file

@ -28,6 +28,7 @@ software-bill-of-materials evidence for managed repositories.
## 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.
The initial extraction milestone `SBOM-WP-0001` provides State Hub
compatibility, SQLite development, PostgreSQL runtime/migrations, and a proven
history importer. `SBOM-WP-0002` owns deployment, external caller cutover, and
production stabilization.

40
alembic.ini Normal file
View file

@ -0,0 +1,40 @@
[alembic]
script_location = migrations
prepend_sys_path = .
path_separator = os
version_path_separator = os
sqlalchemy.url = sqlite:///sbom-nexus.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View file

@ -0,0 +1,74 @@
# PostgreSQL and State Hub history rehearsal — 2026-08-22
## Scope
Evidence for `SBOM-WP-0001-T05`. No production SBOM authority or State Hub rows
were changed. PostgreSQL and Nexus history targets were disposable.
## PostgreSQL migration/runtime proof
A disposable `postgres:16-alpine` instance received:
1. `alembic upgrade head` from an empty database;
2. the PostgreSQL API contract test (health, repository upsert, manual ingest,
current view, and licence report); and
3. `alembic downgrade base`.
All three completed successfully. The container was stopped and removed after
the test.
The production `Containerfile` also built as `sbom-nexus:test`; a disposable
container started as uid 10001 and returned a successful `/state/health`
response using its writable `/data` SQLite development default.
## Live State Hub history baseline
Read-only source: workstation State Hub at `127.0.0.1:8000`.
| Measure | Value |
| --- | ---: |
| Repositories with history | 18 |
| Historical snapshots | 22 |
| Historical entries | 3,123 |
| Oldest snapshot | 2026-03-01T15:12:54.862697Z |
| Newest snapshot | 2026-07-08T20:37:48.255925Z |
## Disposable import result
The first import created all 22 snapshots in a temporary Nexus SQLite database.
Snapshot reconciliation succeeded immediately:
- expected/matched snapshots: 22/22;
- expected/target imported entries: 3,123/3,123;
- missing legacy ids: 0;
- snapshot field mismatches: 0.
The initial licence comparison reported a mismatch solely because the two APIs
returned equal groups in different orders. Group ordering is not contractual;
the importer was corrected to compare normalized groups keyed by licence.
The second run then proved both correctness and idempotence:
- `already_present`: 22;
- snapshot reconciliation: pass;
- licence groups: exact normalized match;
- source/target direct-production copyleft count: 4/4;
- overall result: pass.
## Production gate retained
This is migration-mechanism evidence, not authority cutover approval. Production
still requires a managed PostgreSQL service, backup/restore evidence, deployment
health, State Hub façade and projection changes, Repo Manager retargeting,
Activity Core bounded-ingest activation, and a stabilization window.
## Consumer handoffs
State Hub coordination messages were sent from `sbom-nexus` with the cutover
plan and explicit ownership boundaries:
| Consumer | Message id | Requested child slice |
| --- | --- | --- |
| State Hub | `5a28a4e8-476a-4766-8e11-9c8b4f4cf57a` | reversible compatibility façade and caller retargeting |
| Repo Manager | `62d3bd49-9050-458f-9aa9-b6af348f9eb0` | retarget scanner/report interface and remove competing authority |
| Activity Core | `3420be55-d0ad-4eb7-93b2-42ac4281c61d` | unblock bounded ingest while retaining deployment gates |

View file

@ -7,9 +7,19 @@ 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.
The API defaults to `127.0.0.1:8010` and `./sbom-nexus.db`. SQLite schema is
created automatically for local development.
For PostgreSQL, migrate before starting the API:
```bash
export SBOM_NEXUS_DATABASE_URL='postgresql+psycopg://user:password@host/sbom_nexus'
make migrate
make run
```
PostgreSQL never auto-creates tables unless `SBOM_NEXUS_AUTO_CREATE=1` is set
explicitly. Normal production operation must use Alembic.
## Register and ingest a repository
@ -53,13 +63,13 @@ uv run python scripts/import_state_hub.py \
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.
extraction review. The command fails unless every legacy snapshot id, repository,
timestamp, entry count, licence group, and direct-production copyleft count
reconciles.
## 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.
- Structured operational metrics, 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,83 @@
# SBOM Nexus production cutover plan
**Owner workplan:** `SBOM-WP-0002`
**Parent coordination:** `CUST-WP-0062`
**Consumers:** State Hub, Repo Manager, Activity Core
## Safety model
Cutover separates durable data movement from caller movement. State Hub remains
the rollback read/write path until Nexus history reconciles, the compatibility
façade passes, and the bounded Activity Core flow is proven. No step deletes
State Hub rows.
## Sequence
| Stage | Authority/write path | Exit evidence | Rollback |
| --- | --- | --- | --- |
| 0. Deploy dark | State Hub | Nexus health, migrated PostgreSQL, backup/restore drill | remove dark deployment |
| 1. Import history | State Hub | exact legacy-id, timestamp, entry, and licence reconciliation | discard Nexus database and restore backup |
| 2. Projection sync | State Hub | active repo/path projection in Nexus; catch-up counts sampled | stop projection sync |
| 3. Read façade | State Hub write; Nexus read behind flag | route compatibility suite and dashboard/MCP samples | flag reads back to State Hub |
| 4. Write façade | Nexus write; State Hub projection update | manual and repository ingest parity; truthful attempt/success mapping | flag writes back to State Hub |
| 5. Bounded activity | Nexus | at most N terminal outcomes, zero spawned tasks, deterministic progress | disable daily definition |
| 6. Stabilize | Nexus | two successful daily fires and one Monday with weekly flood at zero | return façade flags to State Hub |
| 7. Retire | Nexus | retention decision and final backup | restore retained State Hub snapshot store during window |
## Contract ownership
### SBOM Nexus
- `/sbom/*`, snapshots, entries, licence report, ingest outcomes, catch-up;
- both `last_attempt_at` and `last_success_at`;
- imported legacy UUID provenance;
- PostgreSQL schema and migration history.
### State Hub child change
- introduce a configurable Nexus client and `/sbom/*` façade;
- preserve legacy response shapes and `ManagedRepo.last_sbom_at` during the
transition;
- retarget dashboard, MCP, summary cache, DoI C8, onboarding, and CLI callers;
- meter façade reads/writes and retain a reversible flag;
- do not add new SBOM product behavior locally.
### Repo Manager child change
- retain repository identity, active status, host/checkout paths, and source
authority;
- change `rmgr sbom scan|licence-report` from independent product behavior to a
Nexus client/local compatibility adapter;
- pin `sbom-nexus.snapshot.v1` and remove competing historical ownership;
- preserve repository-source scanning usability when Nexus is unavailable only
as an explicitly non-authoritative local preview.
### Activity Core child change
- replace the stale test-double blocker note with the live Nexus contract;
- implement at-most-N `POST /sbom/{slug}/ingest` calls for selected targets;
- record `ingested` and skip reasons without task creation;
- keep the definition disabled until dark deployment and import pass;
- enable and capture two-fire plus Monday-window evidence.
## Timestamp compatibility decision needed
State Hub has one `last_sbom_at`; Nexus distinguishes attempt from success. The
recommended transitional mapping is `last_attempt_at`, because it preserves
catch-up fairness and the historical behavior that an ingest call advances the
field. New consumers must use `last_success_at` when they mean inventory
freshness. The State Hub child workplan must record this explicitly before write
cutover.
## Production acceptance
- PostgreSQL upgrade and restore are rehearsed against the deployed topology.
- Historical import report is `ok: true` with zero missing/mismatched snapshots.
- State Hub compatibility routes pass against Nexus.
- Repo Manager has no competing durable SBOM store.
- Activity Core updates or terminally skips at most N repositories per fire.
- The weekly flood remains disabled and creates zero tasks.
- After stabilization, State Hub SBOM rows are retained or removed only through
an explicit retention decision.

59
migrations/env.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sbom_nexus.database import database_url, metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = metadata
configured_target = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv(
"SBOM_NEXUS_DATABASE_PATH"
)
if configured_target:
config.set_main_option(
"sqlalchemy.url",
database_url(configured_target).replace("%", "%%"),
)
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
migrations/script.py.mako Normal file
View file

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,88 @@
"""Initial SBOM Nexus repository, snapshot, and entry schema.
Revision ID: 0001
Revises:
Create Date: 2026-08-22
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"repositories",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=200), nullable=False),
sa.Column("checkout_path", sa.Text(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status", sa.String(length=50), nullable=True),
sa.Column("last_source", sa.String(length=200), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_repositories")),
sa.UniqueConstraint("slug", name=op.f("uq_repositories_slug")),
)
op.create_table(
"snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("repo_id", sa.String(length=36), nullable=False),
sa.Column("snapshot_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("source", sa.String(length=200), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("entry_count", sa.Integer(), nullable=False),
sa.Column("source_revision", sa.String(length=200), nullable=True),
sa.Column("sources_json", sa.JSON(), nullable=False),
sa.Column("errors_json", sa.JSON(), nullable=False),
sa.Column("legacy_id", sa.String(length=100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["repo_id"], ["repositories.id"], name=op.f("fk_snapshots_repo_id_repositories"), ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_snapshots")),
sa.UniqueConstraint("legacy_id", name=op.f("uq_snapshots_legacy_id")),
)
op.create_index("ix_snapshots_repo_time", "snapshots", ["repo_id", "snapshot_at"])
op.create_table(
"entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("repo_id", sa.String(length=36), nullable=False),
sa.Column("snapshot_id", sa.String(length=36), nullable=False),
sa.Column("package_name", sa.String(length=300), nullable=False),
sa.Column("package_version", sa.String(length=100), nullable=True),
sa.Column("ecosystem", sa.String(length=50), nullable=False),
sa.Column("license_spdx", sa.String(length=100), nullable=True),
sa.Column("is_direct", sa.Boolean(), nullable=False),
sa.Column("is_dev", sa.Boolean(), nullable=False),
sa.Column("source_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["repo_id"], ["repositories.id"], name=op.f("fk_entries_repo_id_repositories"), ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["snapshot_id"], ["snapshots.id"], name=op.f("fk_entries_snapshot_id_snapshots"), ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_entries")),
)
op.create_index("ix_entries_license", "entries", ["license_spdx"])
op.create_index("ix_entries_repo", "entries", ["repo_id"])
op.create_index("ix_entries_snapshot", "entries", ["snapshot_id"])
def downgrade() -> None:
op.drop_index("ix_entries_snapshot", table_name="entries")
op.drop_index("ix_entries_repo", table_name="entries")
op.drop_index("ix_entries_license", table_name="entries")
op.drop_table("entries")
op.drop_index("ix_snapshots_repo_time", table_name="snapshots")
op.drop_table("snapshots")
op.drop_table("repositories")

View file

@ -9,9 +9,12 @@ description = "SBOM capture, history, evaluation, and bounded catch-up service"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"alembic>=1.14",
"fastapi>=0.115",
"pydantic>=2.10",
"psycopg[binary]>=3.2",
"pyyaml>=6.0",
"sqlalchemy>=2.0",
"uvicorn>=0.34",
]

View file

@ -1,125 +1,7 @@
#!/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)
"""Compatibility entry point for the packaged State Hub history importer."""
from sbom_nexus.importer import main
if __name__ == "__main__":
main()

View file

@ -12,7 +12,9 @@ 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_DATABASE_TARGET = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv(
"SBOM_NEXUS_DATABASE_PATH", "sbom-nexus.db"
)
DEFAULT_STALE_DAYS = int(os.getenv("SBOM_NEXUS_STALE_DAYS", "30"))
@ -74,19 +76,28 @@ def _validate_entries(entries: list[EntryCreate]) -> list[dict[str, Any]]:
return result
def _auto_create(store: Store) -> bool:
configured = os.getenv("SBOM_NEXUS_AUTO_CREATE")
if configured is not None:
return configured.lower() in {"1", "true", "yes"}
return store.dialect == "sqlite"
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.state.store = Store(database_path or DEFAULT_DATABASE_TARGET)
if _auto_create(application.state.store):
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"}
store = _store(request)
store.health()
return {"status": "ok", "store": "connected", "dialect": store.dialect}
@application.put("/repositories/{repo_slug}")
def upsert_repository(

View file

@ -11,7 +11,9 @@ from sbom_nexus.scanner import scan_repository
def _serve(args: argparse.Namespace) -> None:
if args.database:
if args.database_url:
os.environ["SBOM_NEXUS_DATABASE_URL"] = args.database_url
elif args.database:
os.environ["SBOM_NEXUS_DATABASE_PATH"] = str(Path(args.database).resolve())
import uvicorn
@ -46,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser:
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("--database-url", help="SQLAlchemy database URL (PostgreSQL in production)")
serve.add_argument("--reload", action="store_true")
serve.set_defaults(handler=_serve)

128
src/sbom_nexus/database.py Normal file
View file

@ -0,0 +1,128 @@
"""Portable SQLAlchemy schema shared by the runtime store and Alembic."""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
event,
)
from sqlalchemy.engine import Engine
NAMING_CONVENTION = {
"ix": "ix_%(table_name)s_%(column_0_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=NAMING_CONVENTION)
repositories = Table(
"repositories",
metadata,
Column("id", String(36), primary_key=True),
Column("slug", String(200), nullable=False, unique=True),
Column("checkout_path", Text, nullable=True),
Column("active", Boolean, nullable=False),
Column("last_attempt_at", DateTime(timezone=True), nullable=True),
Column("last_success_at", DateTime(timezone=True), nullable=True),
Column("last_status", String(50), nullable=True),
Column("last_source", String(200), nullable=True),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
)
snapshots = Table(
"snapshots",
metadata,
Column("id", String(36), primary_key=True),
Column(
"repo_id",
String(36),
ForeignKey("repositories.id", ondelete="RESTRICT"),
nullable=False,
),
Column("snapshot_at", DateTime(timezone=True), nullable=False),
Column("source", String(200), nullable=False),
Column("status", String(50), nullable=False),
Column("entry_count", Integer, nullable=False),
Column("source_revision", String(200), nullable=True),
Column("sources_json", JSON, nullable=False),
Column("errors_json", JSON, nullable=False),
Column("legacy_id", String(100), nullable=True, unique=True),
Column("created_at", DateTime(timezone=True), nullable=False),
)
entries = Table(
"entries",
metadata,
Column("id", String(36), primary_key=True),
Column(
"repo_id",
String(36),
ForeignKey("repositories.id", ondelete="RESTRICT"),
nullable=False,
),
Column(
"snapshot_id",
String(36),
ForeignKey("snapshots.id", ondelete="RESTRICT"),
nullable=False,
),
Column("package_name", String(300), nullable=False),
Column("package_version", String(100), nullable=True),
Column("ecosystem", String(50), nullable=False),
Column("license_spdx", String(100), nullable=True),
Column("is_direct", Boolean, nullable=False),
Column("is_dev", Boolean, nullable=False),
Column("source_path", Text, nullable=True),
Column("created_at", DateTime(timezone=True), nullable=False),
)
Index("ix_snapshots_repo_time", snapshots.c.repo_id, snapshots.c.snapshot_at)
Index("ix_entries_snapshot", entries.c.snapshot_id)
Index("ix_entries_repo", entries.c.repo_id)
Index("ix_entries_license", entries.c.license_spdx)
def database_url(value: str | Path) -> str:
"""Normalize a filesystem path or supported URL into a SQLAlchemy URL."""
text = str(value)
if "://" not in text:
return f"sqlite:///{Path(text).resolve()}"
if text.startswith("postgres://"):
return text.replace("postgres://", "postgresql+psycopg://", 1)
if text.startswith("postgresql://"):
return text.replace("postgresql://", "postgresql+psycopg://", 1)
return text
def create_database_engine(value: str | Path) -> Engine:
url = database_url(value)
options: dict[str, object] = {"pool_pre_ping": True}
if url.startswith("sqlite:"):
options["connect_args"] = {"check_same_thread": False}
engine = create_engine(url, **options)
if engine.dialect.name == "sqlite":
@event.listens_for(engine, "connect")
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
return engine

197
src/sbom_nexus/importer.py Normal file
View file

@ -0,0 +1,197 @@
"""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 normalise_licence_groups(groups: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Compare the report as a set of groups; API ordering is not contractual."""
return {
json.dumps(group.get("license_spdx"), sort_keys=True): {
"license_spdx": group.get("license_spdx"),
"count": group.get("count"),
"repos": sorted(group.get("repos") or []),
"is_copyleft": bool(group.get("is_copyleft")),
}
for group in groups
}
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
expected: dict[str, dict[str, Any]] = {}
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)
expected[str(snapshot["id"])] = {
"repo_slug": repo_slug,
"snapshot_at": snapshot["snapshot_at"],
"entry_count": 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
reconciliation: dict[str, Any] | None = None
licence_reconciliation: dict[str, Any] | None = None
if not dry_run:
target_snapshots = request_json(target_url, "/sbom/snapshots/")
imported = {
str(snapshot["legacy_id"]): snapshot
for snapshot in target_snapshots
if snapshot.get("legacy_id")
}
missing = sorted(set(expected) - set(imported))
mismatched = []
for legacy_id in sorted(set(expected) & set(imported)):
wanted = expected[legacy_id]
actual = imported[legacy_id]
differences = {
field: {"source": wanted[field], "target": actual.get(field)}
for field in ("repo_slug", "snapshot_at", "entry_count")
if wanted[field] != actual.get(field)
}
if differences:
mismatched.append({"legacy_id": legacy_id, "differences": differences})
reconciliation = {
"ok": not missing and not mismatched,
"expected_snapshot_count": len(expected),
"matched_snapshot_count": len(expected) - len(missing) - len(mismatched),
"expected_entry_count": source_entries,
"target_imported_entry_count": sum(
imported[legacy_id]["entry_count"]
for legacy_id in expected
if legacy_id in imported
),
"missing_legacy_ids": missing,
"mismatched_snapshots": mismatched,
}
source_licences = request_json(source_url, "/sbom/report/licences/")
target_licences = request_json(target_url, "/sbom/report/licences/")
source_groups = normalise_licence_groups(source_licences.get("groups", []))
target_groups = normalise_licence_groups(target_licences.get("groups", []))
licence_reconciliation = {
"ok": source_groups == target_groups
and source_licences.get("copyleft_direct_count")
== target_licences.get("copyleft_direct_count"),
"groups_match": source_groups == target_groups,
"source_copyleft_direct_count": source_licences.get("copyleft_direct_count"),
"target_copyleft_direct_count": target_licences.get("copyleft_direct_count"),
}
ok = results["missing_repo"] == 0
if reconciliation is not None:
ok = ok and reconciliation["ok"]
if licence_reconciliation is not None:
ok = ok and licence_reconciliation["ok"]
return {
"ok": ok,
"dry_run": dry_run,
"source_repo_count": len(by_repo),
"source_snapshot_count": len(snapshots),
"source_entry_count": source_entries,
"results": dict(results),
"snapshots_by_repo": dict(sorted(by_repo.items())),
"snapshot_reconciliation": reconciliation,
"licence_reconciliation": licence_reconciliation,
}
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

@ -1,19 +1,23 @@
"""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.
"""
"""Transactional persistence for SQLite development and PostgreSQL production."""
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 sqlalchemy import func, insert, select, update
from sqlalchemy.engine import RowMapping
from sbom_nexus.database import (
create_database_engine,
metadata,
repositories,
snapshots,
)
from sbom_nexus.database import entries as entry_table
from sbom_nexus.scanner import is_copyleft
SUCCESS_STATUSES = frozenset({"ingested", "imported"})
@ -23,84 +27,37 @@ def utc_now() -> datetime:
return datetime.now(UTC)
def parse_datetime(value: datetime | str) -> datetime:
parsed = (
datetime.fromisoformat(value.replace("Z", "+00:00"))
if isinstance(value, str)
else value
)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(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"))
return parse_datetime(value).isoformat().replace("+00:00", "Z")
class Store:
def __init__(self, database_path: str | Path) -> None:
self.database_path = str(database_path)
def __init__(self, database_target: str | Path) -> None:
self.engine = create_database_engine(database_target)
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
@property
def dialect(self) -> str:
return self.engine.dialect.name
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
);
metadata.create_all(self.engine)
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 health(self) -> None:
with self.engine.connect() as connection:
connection.execute(select(func.count()).select_from(repositories))
def upsert_repository(
self,
@ -111,59 +68,57 @@ class Store:
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:
now = utc_now()
attempt = parse_datetime(last_attempt_at) if last_attempt_at else None
success = parse_datetime(last_success_at) if last_success_at else None
with self.engine.begin() as connection:
existing = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
select(repositories).where(repositories.c.slug == slug)
).mappings().one_or_none()
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),
update(repositories)
.where(repositories.c.slug == slug)
.values(
checkout_path=checkout_path,
active=active,
last_attempt_at=attempt or existing["last_attempt_at"],
last_success_at=success or existing["last_success_at"],
updated_at=now,
)
)
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,
),
insert(repositories).values(
id=str(uuid.uuid4()),
slug=slug,
checkout_path=checkout_path,
active=active,
last_attempt_at=attempt,
last_success_at=success,
last_status=None,
last_source=None,
created_at=now,
updated_at=now,
)
)
row = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
select(repositories).where(repositories.c.slug == slug)
).mappings().one()
return self._repository_dict(row)
def get_repository(self, slug: str) -> dict[str, Any] | None:
with self.connect() as connection:
with self.engine.connect() as connection:
row = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (slug,)
).fetchone()
select(repositories).where(repositories.c.slug == slug)
).mappings().one_or_none()
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()
with self.engine.connect() as connection:
rows = connection.execute(
select(repositories).order_by(repositories.c.slug)
).mappings().all()
return [self._repository_dict(row) for row in rows]
def record_snapshot(
@ -180,122 +135,110 @@ class Store:
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:
timestamp = parse_datetime(snapshot_at or utc_now())
created_at = utc_now()
with self.engine.begin() as connection:
repo = connection.execute(
"SELECT * FROM repositories WHERE slug = ?", (repo_slug,)
).fetchone()
select(repositories).where(repositories.c.slug == repo_slug)
).mappings().one_or_none()
if repo is None:
raise KeyError(repo_slug)
if legacy_id:
existing = connection.execute(
"SELECT * FROM snapshots WHERE legacy_id = ?", (legacy_id,)
).fetchone()
select(snapshots).where(snapshots.c.legacy_id == legacy_id)
).mappings().one_or_none()
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,
),
insert(snapshots).values(
id=new_snapshot_id,
repo_id=repo["id"],
snapshot_at=timestamp,
source=source,
status=status,
entry_count=len(entries),
source_revision=source_revision,
sources_json=sources or [],
errors_json=errors or [],
legacy_id=legacy_id,
created_at=created_at,
)
)
for entry in entries:
if 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,
),
insert(entry_table),
[
{
"id": str(uuid.uuid4()),
"repo_id": repo["id"],
"snapshot_id": new_snapshot_id,
"package_name": entry["package_name"],
"package_version": entry.get("package_version"),
"ecosystem": entry["ecosystem"],
"license_spdx": entry.get("license_spdx"),
"is_direct": bool(entry.get("is_direct", True)),
"is_dev": bool(entry.get("is_dev", False)),
"source_path": entry.get("source_path"),
"created_at": created_at,
}
for entry in entries
],
)
previous_attempt = repo["last_attempt_at"]
is_latest = previous_attempt is None or parse_datetime(timestamp) >= parse_datetime(
previous_attempt
)
is_latest = previous_attempt is None or 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"]),
update(repositories)
.where(repositories.c.id == repo["id"])
.values(
last_attempt_at=timestamp,
last_success_at=last_success,
last_status=status,
last_source=source,
updated_at=created_at,
)
)
row = connection.execute(
"SELECT * FROM snapshots WHERE id = ?", (new_snapshot_id,)
).fetchone()
select(snapshots).where(snapshots.c.id == new_snapshot_id)
).mappings().one()
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, ...] = ()
statement = (
select(snapshots, repositories.c.slug.label("repo_slug"))
.join(repositories, repositories.c.id == snapshots.c.repo_id)
.order_by(snapshots.c.snapshot_at.desc(), snapshots.c.created_at.desc())
)
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()
statement = statement.where(repositories.c.slug == repo_slug)
with self.engine.connect() as connection:
rows = connection.execute(statement).mappings().all()
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()
statement = (
select(snapshots, repositories.c.slug.label("repo_slug"))
.join(repositories, repositories.c.id == snapshots.c.repo_id)
.where(snapshots.c.id == snapshot_id)
)
with self.engine.connect() as connection:
row = connection.execute(statement).mappings().one_or_none()
if row is None:
return None
entries = connection.execute(
"SELECT * FROM entries WHERE snapshot_id = ? ORDER BY package_name",
(snapshot_id,),
).fetchall()
entry_rows = connection.execute(
select(entry_table)
.where(entry_table.c.snapshot_id == snapshot_id)
.order_by(entry_table.c.package_name)
).mappings().all()
result = self._snapshot_dict(row)
result["entries"] = []
for entry in entries:
for entry in entry_rows:
rendered = self._entry_dict(entry)
rendered["snapshot_at"] = result["snapshot_at"]
result["entries"].append(rendered)
@ -310,57 +253,57 @@ class Store:
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)),
ranked = self._ranked_snapshots()
statement = (
select(
entry_table,
repositories.c.slug.label("repo_slug"),
snapshots.c.snapshot_at.label("entry_snapshot_at"),
)
.join(repositories, repositories.c.id == entry_table.c.repo_id)
.join(snapshots, snapshots.c.id == entry_table.c.snapshot_id)
.join(ranked, ranked.c.id == entry_table.c.snapshot_id)
.where(ranked.c.snapshot_rank == 1)
)
if repo_slug:
statement = statement.where(repositories.c.slug == repo_slug)
for column, value in (
(entry_table.c.ecosystem, ecosystem),
(entry_table.c.license_spdx, license_spdx),
(entry_table.c.is_direct, is_direct),
(entry_table.c.is_dev, 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()
statement = statement.where(column == value)
statement = statement.order_by(entry_table.c.package_name, repositories.c.slug)
with self.engine.connect() as connection:
rows = connection.execute(statement).mappings().all()
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)
current_entries = self.latest_entries(repo_slug=repo_slug)
history = 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,
"entry_count": len(current_entries),
"snapshot_id": history[0]["id"] if history else None,
"entries": current_entries,
}
def licence_report(self) -> dict[str, Any]:
entries = self.latest_entries()
current_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:
for entry in current_entries:
license_id = entry.get("license_spdx")
groups[license_id]["count"] += 1
groups[license_id]["repos"].add(entry["repo_slug"])
@ -374,7 +317,9 @@ class Store:
"source_path": entry.get("source_path"),
}
)
sorted_groups = sorted(groups.items(), key=lambda item: (-item[1]["count"], item[0] or ""))
sorted_groups = sorted(
groups.items(), key=lambda item: (-item[1]["count"], item[0] or "")
)
return {
"groups": [
{
@ -398,13 +343,13 @@ class Store:
now: datetime | None = None,
) -> dict[str, Any]:
effective_limit = max(1, min(25, int(limit)))
current = now or utc_now()
current = parse_datetime(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]
active_repositories = [repo for repo in self.list_repositories() if repo["active"]]
never = [repo for repo in active_repositories if repo["last_attempt_at"] is None]
stale = [
repo
for repo in repositories
for repo in active_repositories
if repo["last_attempt_at"] is None
or parse_datetime(repo["last_attempt_at"]) < cutoff
]
@ -421,69 +366,60 @@ class Store:
"selected_count": len(selected),
"stale_count": len(stale),
"never_count": len(never),
"total_count": len(repositories),
"total_count": len(active_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
@staticmethod
def _ranked_snapshots():
return select(
snapshots.c.id,
func.row_number()
.over(
partition_by=snapshots.c.repo_id,
order_by=(snapshots.c.snapshot_at.desc(), snapshots.c.created_at.desc()),
)
"""
params: tuple[Any, ...] = ()
if repo_slug:
query += " AND r.slug = ?"
params = (repo_slug,)
with self.connect() as connection:
return connection.execute(query, params).fetchall()
.label("snapshot_rank"),
).subquery("ranked_snapshots")
@staticmethod
def _repository_dict(row: sqlite3.Row) -> dict[str, Any]:
def _repository_dict(row: RowMapping) -> 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_attempt_at": datetime_text(row["last_attempt_at"]),
"last_success_at": datetime_text(row["last_success_at"]),
"last_status": row["last_status"],
"last_source": row["last_source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"created_at": datetime_text(row["created_at"]),
"updated_at": datetime_text(row["updated_at"]),
}
@staticmethod
def _snapshot_dict(row: sqlite3.Row) -> dict[str, Any]:
def _snapshot_dict(row: RowMapping) -> dict[str, Any]:
result = {
"id": row["id"],
"repo_id": row["repo_id"],
"snapshot_at": row["snapshot_at"],
"snapshot_at": datetime_text(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"]),
"sources": row["sources_json"] or [],
"errors": row["errors_json"] or [],
"legacy_id": row["legacy_id"],
"created_at": row["created_at"],
"created_at": datetime_text(row["created_at"]),
}
if "repo_slug" in row.keys():
if "repo_slug" in row:
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]:
def _entry_dict(row: RowMapping) -> dict[str, Any]:
result = {
"id": row["id"],
"repo_id": row["repo_id"],
@ -495,10 +431,12 @@ class Store:
"is_direct": bool(row["is_direct"]),
"is_dev": bool(row["is_dev"]),
"source_path": row["source_path"],
"created_at": row["created_at"],
"created_at": datetime_text(row["created_at"]),
}
if "repo_slug" in row.keys():
if "repo_slug" in row:
result["repo_slug"] = row["repo_slug"]
if "entry_snapshot_at" in row:
result["snapshot_at"] = datetime_text(row["entry_snapshot_at"])
return result
@staticmethod

View file

@ -33,7 +33,11 @@ def register(
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"}
assert client.get("/state/health").json() == {
"status": "ok",
"store": "connected",
"dialect": "sqlite",
}
register(client, "demo")
response = client.post(

View file

@ -0,0 +1,103 @@
from __future__ import annotations
from typing import Any
from sbom_nexus import importer
def test_import_reconciles_history_licences_and_is_idempotent(monkeypatch) -> None:
target_snapshots: list[dict[str, Any]] = []
groups = [
{
"license_spdx": "MIT",
"count": 1,
"repos": ["demo"],
"is_copyleft": False,
},
{
"license_spdx": None,
"count": 2,
"repos": ["zeta", "demo"],
"is_copyleft": False,
},
]
def fake_request(
base_url: str,
path: str,
*,
method: str = "GET",
body: dict[str, Any] | None = None,
) -> Any:
if base_url == "source":
if path == "/repos/":
return [
{
"id": "repo-id",
"slug": "demo",
"status": "active",
"local_path": "/repos/demo",
}
]
if path == "/sbom/snapshots/":
return [
{
"id": "snapshot-id",
"repo_id": "repo-id",
"snapshot_at": "2026-01-02T03:04:05Z",
"source": "manual",
"entry_count": 1,
}
]
if path == "/sbom/snapshots/snapshot-id":
return {
"entries": [
{
"package_name": "example",
"package_version": "1.0",
"ecosystem": "other",
"license_spdx": "MIT",
"is_direct": True,
"is_dev": False,
}
]
}
if path == "/sbom/report/licences/":
return {"groups": list(reversed(groups)), "copyleft_direct_count": 0}
if base_url == "target":
if method == "PUT":
return {"slug": "demo"}
if method == "POST" and path == "/sbom/import/":
assert body is not None
already_present = any(
snapshot["legacy_id"] == body["legacy_id"]
for snapshot in target_snapshots
)
if not already_present:
target_snapshots.append(
{
"legacy_id": body["legacy_id"],
"repo_slug": body["repo_slug"],
"snapshot_at": body["snapshot_at"],
"entry_count": len(body["entries"]),
}
)
return {"imported": not already_present}
if path == "/sbom/snapshots/":
return target_snapshots
if path == "/sbom/report/licences/":
return {"groups": groups, "copyleft_direct_count": 0}
raise AssertionError((base_url, method, path))
monkeypatch.setattr(importer, "request_json", fake_request)
first = importer.import_history("source", "target", dry_run=False)
second = importer.import_history("source", "target", dry_run=False)
assert first["ok"] is True
assert first["results"] == {"imported": 1}
assert first["snapshot_reconciliation"]["matched_snapshot_count"] == 1
assert first["licence_reconciliation"]["groups_match"] is True
assert second["ok"] is True
assert second["results"] == {"already_present": 1}
assert len(target_snapshots) == 1

38
tests/test_migrations.py Normal file
View file

@ -0,0 +1,38 @@
from __future__ import annotations
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
def migration_config(database_path: Path) -> Config:
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", f"sqlite:///{database_path}")
return config
def test_initial_migration_upgrades_and_downgrades(tmp_path: Path) -> None:
database_path = tmp_path / "migrated.db"
config = migration_config(database_path)
command.upgrade(config, "head")
engine = create_engine(f"sqlite:///{database_path}")
assert set(inspect(engine).get_table_names()) == {
"alembic_version",
"entries",
"repositories",
"snapshots",
}
assert {index["name"] for index in inspect(engine).get_indexes("entries")} == {
"ix_entries_license",
"ix_entries_repo",
"ix_entries_snapshot",
}
command.check(config)
command.downgrade(config, "base")
assert set(inspect(engine).get_table_names()) == {"alembic_version"}

View file

@ -0,0 +1,52 @@
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
from sbom_nexus.api import create_app
POSTGRES_URL = os.getenv("SBOM_NEXUS_TEST_POSTGRES_URL")
pytestmark = pytest.mark.skipif(
not POSTGRES_URL,
reason="SBOM_NEXUS_TEST_POSTGRES_URL is not configured",
)
def test_migrated_postgres_runtime_contract() -> None:
assert POSTGRES_URL is not None
client = TestClient(create_app(POSTGRES_URL))
assert client.get("/state/health").json() == {
"status": "ok",
"store": "connected",
"dialect": "postgresql",
}
response = client.put(
"/repositories/postgres-contract",
json={"active": True},
)
assert response.status_code == 200
ingest = client.post(
"/sbom/ingest/",
json={
"repo_slug": "postgres-contract",
"entries": [
{
"package_name": "psycopg",
"package_version": "3.2",
"ecosystem": "python",
"license_spdx": "LGPL-3.0-only",
"is_direct": True,
"is_dev": False,
}
],
},
)
assert ingest.status_code == 200
assert ingest.json()["ingested"] == 1
assert client.get("/sbom/postgres-contract").json()["entry_count"] == 1
assert client.get("/sbom/report/licences/").json()["copyleft_direct_count"] == 1

303
uv.lock generated
View file

@ -1,6 +1,20 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "alembic"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946 },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
@ -78,6 +92,83 @@ 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 = "greenlet"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165 },
{ url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610 },
{ url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481 },
{ url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329 },
{ url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562 },
{ url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145 },
{ url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909 },
{ url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184 },
{ url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280 },
{ url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785 },
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809 },
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910 },
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198 },
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504 },
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439 },
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462 },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342 },
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744 },
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171 },
{ url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362 },
{ url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730 },
{ url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536 },
{ url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924 },
{ url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726 },
{ url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906 },
{ url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966 },
{ url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430 },
{ url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684 },
{ url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075 },
{ url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260 },
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309 },
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185 },
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419 },
{ url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863 },
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822 },
{ url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554 },
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469 },
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952 },
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458 },
{ url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146 },
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471 },
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470 },
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973 },
{ url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587 },
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634 },
{ url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175 },
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320 },
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412 },
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514 },
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975 },
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823 },
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613 },
{ url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436 },
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930 },
{ url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404 },
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878 },
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597 },
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700 },
{ url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750 },
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387 },
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472 },
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238 },
{ url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022 },
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246 },
{ url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951 },
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735 },
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925 },
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829 },
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093 },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -133,6 +224,92 @@ 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 = "mako"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010 },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
]
[[package]]
name = "packaging"
version = "26.3"
@ -151,6 +328,75 @@ 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 = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001 },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813 },
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799 },
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050 },
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428 },
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746 },
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033 },
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175 },
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203 },
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509 },
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551 },
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054 },
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122 },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943 },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697 },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995 },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180 },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828 },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757 },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546 },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197 },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627 },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782 },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377 },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023 },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423 },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137 },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671 },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601 },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513 },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243 },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347 },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393 },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592 },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292 },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023 },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985 },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745 },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486 },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427 },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549 },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256 },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204 },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811 },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849 },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@ -378,9 +624,12 @@ name = "sbom-nexus"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "uvicorn" },
]
@ -393,9 +642,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.10" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "uvicorn", specifier = ">=0.34" },
]
@ -406,6 +658,48 @@ dev = [
{ name = "ruff", specifier = ">=0.9" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460 },
{ url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720 },
{ url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394 },
{ url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991 },
{ url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454 },
{ url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248 },
{ url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943 },
{ url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940 },
{ url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415 },
{ url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577 },
{ url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225 },
{ url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374 },
{ url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366 },
{ url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344 },
{ url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724 },
{ url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189 },
{ url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185 },
{ url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698 },
{ url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936 },
{ url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308 },
{ url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876 },
{ url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537 },
{ url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606 },
{ url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642 },
{ url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125 },
{ url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516 },
{ url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249 },
{ url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718 },
{ url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344 },
{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700 },
]
[[package]]
name = "starlette"
version = "1.6.0"
@ -440,6 +734,15 @@ 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 = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 },
]
[[package]]
name = "uvicorn"
version = "0.52.4"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Bootstrap SBOM Nexus and extract the State Hub SBOM product"
domain: infotech
repo: sbom-nexus
status: active
status: finished
owner: codex
topic_slug: infotech
created: "2026-08-22"
@ -79,7 +79,7 @@ licence report, catch-up selection, and terminal skip behavior with tests.
```task
id: SBOM-WP-0001-T05
status: todo
status: done
priority: high
state_hub_task_id: "5e281ac9-400d-5a38-b82a-b3c2198a5e7c"
```
@ -87,3 +87,9 @@ state_hub_task_id: "5e281ac9-400d-5a38-b82a-b3c2198a5e7c"
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.
Completed with a SQLAlchemy SQLite/PostgreSQL store, Alembic baseline and
disposable PostgreSQL upgrade/runtime/downgrade proof. A live disposable import
reconciled 22 snapshots, 3,123 entries, all licence groups, and copyleft count;
the second run was 22/22 idempotent. Production residuals moved to
`SBOM-WP-0002` and were routed to the three consumer owners.

View file

@ -0,0 +1,105 @@
---
id: SBOM-WP-0002
type: workplan
title: "Deploy and cut over SBOM Nexus production authority"
domain: infotech
repo: sbom-nexus
status: ready
owner: codex
topic_slug: infotech
created: "2026-08-22"
updated: "2026-08-22"
parent_workplan: CUST-WP-0062
related:
- SBOM-WP-0001
- CUST-WP-0062
- ACTIVITY-WP-0030
- STATE-WP-0079
- RMGR-WP-0008
---
# Deploy and cut over SBOM Nexus production authority
## Goal
Deploy SBOM Nexus with managed PostgreSQL, import and reconcile State Hub
history, move callers through reversible compatibility stages, and prove the
bounded daily catch-up before retiring State Hub SBOM ownership.
## Deploy dark with managed PostgreSQL
```task
id: SBOM-WP-0002-T01
status: todo
priority: high
```
Provision database credentials through the governed route, migrate schema,
deploy the API without callers, and capture health plus backup/restore evidence.
## Synchronize repository projections
```task
id: SBOM-WP-0002-T02
status: todo
priority: high
```
Populate active repository identity and host checkout paths from Repo Manager.
Verify fleet totals and catch-up ordering without performing ingest.
## Import and reconcile State Hub history
```task
id: SBOM-WP-0002-T03
status: wait
priority: high
```
Depends on T01/T02. Back up the empty target, run the idempotent importer, and
retain an exact reconciliation report before any caller switch.
## Cut over State Hub compatibility façade
```task
id: SBOM-WP-0002-T04
status: wait
priority: high
```
Depends on T03 and the State Hub child change. Move reads then writes behind
reversible flags; retarget dashboard, MCP, CLI, summary, DoI, and onboarding.
## Retarget Repo Manager scanner interface
```task
id: SBOM-WP-0002-T05
status: wait
priority: medium
```
Depends on dark deployment. Preserve CLI usability while removing competing
SBOM product authority and pinning the Nexus contract.
## Enable bounded Activity Core ingest
```task
id: SBOM-WP-0002-T06
status: wait
priority: high
```
Depends on T03/T04 and `ACTIVITY-WP-0030`. Enable no more than N ingests/skips
per fire with zero spawned catch-up tasks.
## Stabilize and retire legacy ownership
```task
id: SBOM-WP-0002-T07
status: wait
priority: medium
```
Capture two successful daily fires and a zero-flood Monday window. Record the
retention decision, then retire State Hub SBOM ownership after the stabilization
window without deleting historical data implicitly.