diff --git a/AGENTS.md b/AGENTS.md index 846a4e3..7a73da9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile index e168002..21ab990 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/ProductRequirementsDocument.md b/ProductRequirementsDocument.md index f65967c..a1952ba 100644 --- a/ProductRequirementsDocument.md +++ b/ProductRequirementsDocument.md @@ -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 diff --git a/README.md b/README.md index 494332c..ae1ec0c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SCOPE.md b/SCOPE.md index cf647a9..e231975 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6643602 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/docs/evidence/SBOM-WP-0001-postgres-and-history-rehearsal-2026-08-22.md b/docs/evidence/SBOM-WP-0001-postgres-and-history-rehearsal-2026-08-22.md new file mode 100644 index 0000000..f71a8a3 --- /dev/null +++ b/docs/evidence/SBOM-WP-0001-postgres-and-history-rehearsal-2026-08-22.md @@ -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 | diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 4fb48d4..a4cff89 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -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. diff --git a/docs/production-cutover-plan.md b/docs/production-cutover-plan.md new file mode 100644 index 0000000..83a239b --- /dev/null +++ b/docs/production-cutover-plan.md @@ -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. diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..1cfc6f5 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1ba49a8 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/0001_initial_schema.py b/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..ebb7887 --- /dev/null +++ b/migrations/versions/0001_initial_schema.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 8fe35e7..5a7c0f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/scripts/import_state_hub.py b/scripts/import_state_hub.py index 2076ec9..40d77ec 100644 --- a/scripts/import_state_hub.py +++ b/scripts/import_state_hub.py @@ -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() diff --git a/src/sbom_nexus/api.py b/src/sbom_nexus/api.py index a0f588b..2da9251 100644 --- a/src/sbom_nexus/api.py +++ b/src/sbom_nexus/api.py @@ -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( diff --git a/src/sbom_nexus/cli.py b/src/sbom_nexus/cli.py index bac6ca4..3b76407 100644 --- a/src/sbom_nexus/cli.py +++ b/src/sbom_nexus/cli.py @@ -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) diff --git a/src/sbom_nexus/database.py b/src/sbom_nexus/database.py new file mode 100644 index 0000000..03de638 --- /dev/null +++ b/src/sbom_nexus/database.py @@ -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 diff --git a/src/sbom_nexus/importer.py b/src/sbom_nexus/importer.py new file mode 100644 index 0000000..927c444 --- /dev/null +++ b/src/sbom_nexus/importer.py @@ -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() diff --git a/src/sbom_nexus/storage.py b/src/sbom_nexus/storage.py index 9f7fb1a..d45ddec 100644 --- a/src/sbom_nexus/storage.py +++ b/src/sbom_nexus/storage.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index 012d986..18ecb92 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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( diff --git a/tests/test_import_state_hub.py b/tests/test_import_state_hub.py new file mode 100644 index 0000000..f3fdac5 --- /dev/null +++ b/tests/test_import_state_hub.py @@ -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 diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..ff6f949 --- /dev/null +++ b/tests/test_migrations.py @@ -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"} diff --git a/tests/test_postgres_integration.py b/tests/test_postgres_integration.py new file mode 100644 index 0000000..33eaca7 --- /dev/null +++ b/tests/test_postgres_integration.py @@ -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 diff --git a/uv.lock b/uv.lock index d7d88da..fbd1520 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md b/workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md index c419ffc..80e1907 100644 --- a/workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md +++ b/workplans/SBOM-WP-0001-bootstrap-and-state-hub-extraction.md @@ -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. diff --git a/workplans/SBOM-WP-0002-production-cutover.md b/workplans/SBOM-WP-0002-production-cutover.md new file mode 100644 index 0000000..69eb066 --- /dev/null +++ b/workplans/SBOM-WP-0002-production-cutover.md @@ -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.