feat: prepare postgres sbom cutover
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
parent
cf7e3acb78
commit
ba535e1f8f
26 changed files with 1573 additions and 411 deletions
|
|
@ -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
|
||||
|
|
|
|||
8
Makefile
8
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
7
SCOPE.md
7
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.
|
||||
|
|
|
|||
40
alembic.ini
Normal file
40
alembic.ini
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite:///sbom-nexus.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
83
docs/production-cutover-plan.md
Normal file
83
docs/production-cutover-plan.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# SBOM Nexus production cutover plan
|
||||
|
||||
**Owner workplan:** `SBOM-WP-0002`
|
||||
|
||||
**Parent coordination:** `CUST-WP-0062`
|
||||
|
||||
**Consumers:** State Hub, Repo Manager, Activity Core
|
||||
|
||||
## Safety model
|
||||
|
||||
Cutover separates durable data movement from caller movement. State Hub remains
|
||||
the rollback read/write path until Nexus history reconciles, the compatibility
|
||||
façade passes, and the bounded Activity Core flow is proven. No step deletes
|
||||
State Hub rows.
|
||||
|
||||
## Sequence
|
||||
|
||||
| Stage | Authority/write path | Exit evidence | Rollback |
|
||||
| --- | --- | --- | --- |
|
||||
| 0. Deploy dark | State Hub | Nexus health, migrated PostgreSQL, backup/restore drill | remove dark deployment |
|
||||
| 1. Import history | State Hub | exact legacy-id, timestamp, entry, and licence reconciliation | discard Nexus database and restore backup |
|
||||
| 2. Projection sync | State Hub | active repo/path projection in Nexus; catch-up counts sampled | stop projection sync |
|
||||
| 3. Read façade | State Hub write; Nexus read behind flag | route compatibility suite and dashboard/MCP samples | flag reads back to State Hub |
|
||||
| 4. Write façade | Nexus write; State Hub projection update | manual and repository ingest parity; truthful attempt/success mapping | flag writes back to State Hub |
|
||||
| 5. Bounded activity | Nexus | at most N terminal outcomes, zero spawned tasks, deterministic progress | disable daily definition |
|
||||
| 6. Stabilize | Nexus | two successful daily fires and one Monday with weekly flood at zero | return façade flags to State Hub |
|
||||
| 7. Retire | Nexus | retention decision and final backup | restore retained State Hub snapshot store during window |
|
||||
|
||||
## Contract ownership
|
||||
|
||||
### SBOM Nexus
|
||||
|
||||
- `/sbom/*`, snapshots, entries, licence report, ingest outcomes, catch-up;
|
||||
- both `last_attempt_at` and `last_success_at`;
|
||||
- imported legacy UUID provenance;
|
||||
- PostgreSQL schema and migration history.
|
||||
|
||||
### State Hub child change
|
||||
|
||||
- introduce a configurable Nexus client and `/sbom/*` façade;
|
||||
- preserve legacy response shapes and `ManagedRepo.last_sbom_at` during the
|
||||
transition;
|
||||
- retarget dashboard, MCP, summary cache, DoI C8, onboarding, and CLI callers;
|
||||
- meter façade reads/writes and retain a reversible flag;
|
||||
- do not add new SBOM product behavior locally.
|
||||
|
||||
### Repo Manager child change
|
||||
|
||||
- retain repository identity, active status, host/checkout paths, and source
|
||||
authority;
|
||||
- change `rmgr sbom scan|licence-report` from independent product behavior to a
|
||||
Nexus client/local compatibility adapter;
|
||||
- pin `sbom-nexus.snapshot.v1` and remove competing historical ownership;
|
||||
- preserve repository-source scanning usability when Nexus is unavailable only
|
||||
as an explicitly non-authoritative local preview.
|
||||
|
||||
### Activity Core child change
|
||||
|
||||
- replace the stale test-double blocker note with the live Nexus contract;
|
||||
- implement at-most-N `POST /sbom/{slug}/ingest` calls for selected targets;
|
||||
- record `ingested` and skip reasons without task creation;
|
||||
- keep the definition disabled until dark deployment and import pass;
|
||||
- enable and capture two-fire plus Monday-window evidence.
|
||||
|
||||
## Timestamp compatibility decision needed
|
||||
|
||||
State Hub has one `last_sbom_at`; Nexus distinguishes attempt from success. The
|
||||
recommended transitional mapping is `last_attempt_at`, because it preserves
|
||||
catch-up fairness and the historical behavior that an ingest call advances the
|
||||
field. New consumers must use `last_success_at` when they mean inventory
|
||||
freshness. The State Hub child workplan must record this explicitly before write
|
||||
cutover.
|
||||
|
||||
## Production acceptance
|
||||
|
||||
- PostgreSQL upgrade and restore are rehearsed against the deployed topology.
|
||||
- Historical import report is `ok: true` with zero missing/mismatched snapshots.
|
||||
- State Hub compatibility routes pass against Nexus.
|
||||
- Repo Manager has no competing durable SBOM store.
|
||||
- Activity Core updates or terminally skips at most N repositories per fire.
|
||||
- The weekly flood remains disabled and creates zero tasks.
|
||||
- After stabilization, State Hub SBOM rows are retained or removed only through
|
||||
an explicit retention decision.
|
||||
59
migrations/env.py
Normal file
59
migrations/env.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from sbom_nexus.database import database_url, metadata
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = metadata
|
||||
|
||||
configured_target = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv(
|
||||
"SBOM_NEXUS_DATABASE_PATH"
|
||||
)
|
||||
if configured_target:
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
database_url(configured_target).replace("%", "%%"),
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
25
migrations/script.py.mako
Normal file
25
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
88
migrations/versions/0001_initial_schema.py
Normal file
88
migrations/versions/0001_initial_schema.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Initial SBOM Nexus repository, snapshot, and entry schema.
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-08-22
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repositories",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=200), nullable=False),
|
||||
sa.Column("checkout_path", sa.Text(), nullable=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("last_source", sa.String(length=200), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_repositories")),
|
||||
sa.UniqueConstraint("slug", name=op.f("uq_repositories_slug")),
|
||||
)
|
||||
op.create_table(
|
||||
"snapshots",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("repo_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("snapshot_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("source", sa.String(length=200), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("entry_count", sa.Integer(), nullable=False),
|
||||
sa.Column("source_revision", sa.String(length=200), nullable=True),
|
||||
sa.Column("sources_json", sa.JSON(), nullable=False),
|
||||
sa.Column("errors_json", sa.JSON(), nullable=False),
|
||||
sa.Column("legacy_id", sa.String(length=100), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["repo_id"], ["repositories.id"], name=op.f("fk_snapshots_repo_id_repositories"), ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_snapshots")),
|
||||
sa.UniqueConstraint("legacy_id", name=op.f("uq_snapshots_legacy_id")),
|
||||
)
|
||||
op.create_index("ix_snapshots_repo_time", "snapshots", ["repo_id", "snapshot_at"])
|
||||
op.create_table(
|
||||
"entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("repo_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("snapshot_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("package_name", sa.String(length=300), nullable=False),
|
||||
sa.Column("package_version", sa.String(length=100), nullable=True),
|
||||
sa.Column("ecosystem", sa.String(length=50), nullable=False),
|
||||
sa.Column("license_spdx", sa.String(length=100), nullable=True),
|
||||
sa.Column("is_direct", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_dev", sa.Boolean(), nullable=False),
|
||||
sa.Column("source_path", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["repo_id"], ["repositories.id"], name=op.f("fk_entries_repo_id_repositories"), ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["snapshot_id"], ["snapshots.id"], name=op.f("fk_entries_snapshot_id_snapshots"), ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_entries")),
|
||||
)
|
||||
op.create_index("ix_entries_license", "entries", ["license_spdx"])
|
||||
op.create_index("ix_entries_repo", "entries", ["repo_id"])
|
||||
op.create_index("ix_entries_snapshot", "entries", ["snapshot_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_entries_snapshot", table_name="entries")
|
||||
op.drop_index("ix_entries_repo", table_name="entries")
|
||||
op.drop_index("ix_entries_license", table_name="entries")
|
||||
op.drop_table("entries")
|
||||
op.drop_index("ix_snapshots_repo_time", table_name="snapshots")
|
||||
op.drop_table("snapshots")
|
||||
op.drop_table("repositories")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ from sbom_nexus.scanner import scan_repository
|
|||
|
||||
|
||||
def _serve(args: argparse.Namespace) -> None:
|
||||
if args.database:
|
||||
if args.database_url:
|
||||
os.environ["SBOM_NEXUS_DATABASE_URL"] = args.database_url
|
||||
elif args.database:
|
||||
os.environ["SBOM_NEXUS_DATABASE_PATH"] = str(Path(args.database).resolve())
|
||||
import uvicorn
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8010)
|
||||
serve.add_argument("--database", help="SQLite database path")
|
||||
serve.add_argument("--database-url", help="SQLAlchemy database URL (PostgreSQL in production)")
|
||||
serve.add_argument("--reload", action="store_true")
|
||||
serve.set_defaults(handler=_serve)
|
||||
|
||||
|
|
|
|||
128
src/sbom_nexus/database.py
Normal file
128
src/sbom_nexus/database.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Portable SQLAlchemy schema shared by the runtime store and Alembic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(table_name)s_%(column_0_name)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
repositories = Table(
|
||||
"repositories",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("slug", String(200), nullable=False, unique=True),
|
||||
Column("checkout_path", Text, nullable=True),
|
||||
Column("active", Boolean, nullable=False),
|
||||
Column("last_attempt_at", DateTime(timezone=True), nullable=True),
|
||||
Column("last_success_at", DateTime(timezone=True), nullable=True),
|
||||
Column("last_status", String(50), nullable=True),
|
||||
Column("last_source", String(200), nullable=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
snapshots = Table(
|
||||
"snapshots",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column(
|
||||
"repo_id",
|
||||
String(36),
|
||||
ForeignKey("repositories.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("snapshot_at", DateTime(timezone=True), nullable=False),
|
||||
Column("source", String(200), nullable=False),
|
||||
Column("status", String(50), nullable=False),
|
||||
Column("entry_count", Integer, nullable=False),
|
||||
Column("source_revision", String(200), nullable=True),
|
||||
Column("sources_json", JSON, nullable=False),
|
||||
Column("errors_json", JSON, nullable=False),
|
||||
Column("legacy_id", String(100), nullable=True, unique=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
entries = Table(
|
||||
"entries",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column(
|
||||
"repo_id",
|
||||
String(36),
|
||||
ForeignKey("repositories.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column(
|
||||
"snapshot_id",
|
||||
String(36),
|
||||
ForeignKey("snapshots.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("package_name", String(300), nullable=False),
|
||||
Column("package_version", String(100), nullable=True),
|
||||
Column("ecosystem", String(50), nullable=False),
|
||||
Column("license_spdx", String(100), nullable=True),
|
||||
Column("is_direct", Boolean, nullable=False),
|
||||
Column("is_dev", Boolean, nullable=False),
|
||||
Column("source_path", Text, nullable=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
Index("ix_snapshots_repo_time", snapshots.c.repo_id, snapshots.c.snapshot_at)
|
||||
Index("ix_entries_snapshot", entries.c.snapshot_id)
|
||||
Index("ix_entries_repo", entries.c.repo_id)
|
||||
Index("ix_entries_license", entries.c.license_spdx)
|
||||
|
||||
|
||||
def database_url(value: str | Path) -> str:
|
||||
"""Normalize a filesystem path or supported URL into a SQLAlchemy URL."""
|
||||
text = str(value)
|
||||
if "://" not in text:
|
||||
return f"sqlite:///{Path(text).resolve()}"
|
||||
if text.startswith("postgres://"):
|
||||
return text.replace("postgres://", "postgresql+psycopg://", 1)
|
||||
if text.startswith("postgresql://"):
|
||||
return text.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
return text
|
||||
|
||||
|
||||
def create_database_engine(value: str | Path) -> Engine:
|
||||
url = database_url(value)
|
||||
options: dict[str, object] = {"pool_pre_ping": True}
|
||||
if url.startswith("sqlite:"):
|
||||
options["connect_args"] = {"check_same_thread": False}
|
||||
engine = create_engine(url, **options)
|
||||
if engine.dialect.name == "sqlite":
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
197
src/sbom_nexus/importer.py
Normal file
197
src/sbom_nexus/importer.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Idempotently import historical State Hub SBOM snapshots into SBOM Nexus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
|
||||
def request_json(
|
||||
base_url: str,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
payload = json.dumps(body).encode() if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}{path}",
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def checkout_path(repo: dict[str, Any]) -> str | None:
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
return host_paths.get(socket.gethostname()) or repo.get("local_path")
|
||||
|
||||
|
||||
def normalise_licence_groups(groups: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Compare the report as a set of groups; API ordering is not contractual."""
|
||||
return {
|
||||
json.dumps(group.get("license_spdx"), sort_keys=True): {
|
||||
"license_spdx": group.get("license_spdx"),
|
||||
"count": group.get("count"),
|
||||
"repos": sorted(group.get("repos") or []),
|
||||
"is_copyleft": bool(group.get("is_copyleft")),
|
||||
}
|
||||
for group in groups
|
||||
}
|
||||
|
||||
|
||||
def import_history(source_url: str, target_url: str, *, dry_run: bool) -> dict[str, Any]:
|
||||
repositories = request_json(source_url, "/repos/")
|
||||
repo_by_id = {str(repo["id"]): repo for repo in repositories}
|
||||
snapshots = request_json(source_url, "/sbom/snapshots/")
|
||||
snapshots.sort(key=lambda item: (item["snapshot_at"], item["id"]))
|
||||
|
||||
results = Counter()
|
||||
by_repo = Counter()
|
||||
source_entries = 0
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for snapshot in snapshots:
|
||||
repo = repo_by_id.get(str(snapshot["repo_id"]))
|
||||
if repo is None:
|
||||
results["missing_repo"] += 1
|
||||
continue
|
||||
repo_slug = repo["slug"]
|
||||
detail = request_json(source_url, f"/sbom/snapshots/{snapshot['id']}")
|
||||
entries = [
|
||||
{
|
||||
"package_name": entry["package_name"],
|
||||
"package_version": entry.get("package_version"),
|
||||
"ecosystem": entry["ecosystem"],
|
||||
"license_spdx": entry.get("license_spdx"),
|
||||
"is_direct": entry.get("is_direct", True),
|
||||
"is_dev": entry.get("is_dev", False),
|
||||
"source_path": entry.get("source_path"),
|
||||
}
|
||||
for entry in detail.get("entries", [])
|
||||
]
|
||||
source_entries += len(entries)
|
||||
expected[str(snapshot["id"])] = {
|
||||
"repo_slug": repo_slug,
|
||||
"snapshot_at": snapshot["snapshot_at"],
|
||||
"entry_count": len(entries),
|
||||
}
|
||||
if dry_run:
|
||||
results["would_import"] += 1
|
||||
by_repo[repo_slug] += 1
|
||||
continue
|
||||
|
||||
request_json(
|
||||
target_url,
|
||||
f"/repositories/{urllib.parse.quote(repo_slug, safe='')}",
|
||||
method="PUT",
|
||||
body={
|
||||
"checkout_path": checkout_path(repo),
|
||||
"active": repo.get("status", "active") == "active",
|
||||
},
|
||||
)
|
||||
imported = request_json(
|
||||
target_url,
|
||||
"/sbom/import/",
|
||||
method="POST",
|
||||
body={
|
||||
"repo_slug": repo_slug,
|
||||
"legacy_id": str(snapshot["id"]),
|
||||
"snapshot_at": snapshot["snapshot_at"],
|
||||
"source": f"state-hub:{snapshot.get('source') or 'manual'}",
|
||||
"entries": entries,
|
||||
},
|
||||
)
|
||||
results["imported" if imported["imported"] else "already_present"] += 1
|
||||
by_repo[repo_slug] += 1
|
||||
|
||||
reconciliation: dict[str, Any] | None = None
|
||||
licence_reconciliation: dict[str, Any] | None = None
|
||||
if not dry_run:
|
||||
target_snapshots = request_json(target_url, "/sbom/snapshots/")
|
||||
imported = {
|
||||
str(snapshot["legacy_id"]): snapshot
|
||||
for snapshot in target_snapshots
|
||||
if snapshot.get("legacy_id")
|
||||
}
|
||||
missing = sorted(set(expected) - set(imported))
|
||||
mismatched = []
|
||||
for legacy_id in sorted(set(expected) & set(imported)):
|
||||
wanted = expected[legacy_id]
|
||||
actual = imported[legacy_id]
|
||||
differences = {
|
||||
field: {"source": wanted[field], "target": actual.get(field)}
|
||||
for field in ("repo_slug", "snapshot_at", "entry_count")
|
||||
if wanted[field] != actual.get(field)
|
||||
}
|
||||
if differences:
|
||||
mismatched.append({"legacy_id": legacy_id, "differences": differences})
|
||||
reconciliation = {
|
||||
"ok": not missing and not mismatched,
|
||||
"expected_snapshot_count": len(expected),
|
||||
"matched_snapshot_count": len(expected) - len(missing) - len(mismatched),
|
||||
"expected_entry_count": source_entries,
|
||||
"target_imported_entry_count": sum(
|
||||
imported[legacy_id]["entry_count"]
|
||||
for legacy_id in expected
|
||||
if legacy_id in imported
|
||||
),
|
||||
"missing_legacy_ids": missing,
|
||||
"mismatched_snapshots": mismatched,
|
||||
}
|
||||
|
||||
source_licences = request_json(source_url, "/sbom/report/licences/")
|
||||
target_licences = request_json(target_url, "/sbom/report/licences/")
|
||||
source_groups = normalise_licence_groups(source_licences.get("groups", []))
|
||||
target_groups = normalise_licence_groups(target_licences.get("groups", []))
|
||||
licence_reconciliation = {
|
||||
"ok": source_groups == target_groups
|
||||
and source_licences.get("copyleft_direct_count")
|
||||
== target_licences.get("copyleft_direct_count"),
|
||||
"groups_match": source_groups == target_groups,
|
||||
"source_copyleft_direct_count": source_licences.get("copyleft_direct_count"),
|
||||
"target_copyleft_direct_count": target_licences.get("copyleft_direct_count"),
|
||||
}
|
||||
|
||||
ok = results["missing_repo"] == 0
|
||||
if reconciliation is not None:
|
||||
ok = ok and reconciliation["ok"]
|
||||
if licence_reconciliation is not None:
|
||||
ok = ok and licence_reconciliation["ok"]
|
||||
return {
|
||||
"ok": ok,
|
||||
"dry_run": dry_run,
|
||||
"source_repo_count": len(by_repo),
|
||||
"source_snapshot_count": len(snapshots),
|
||||
"source_entry_count": source_entries,
|
||||
"results": dict(results),
|
||||
"snapshots_by_repo": dict(sorted(by_repo.items())),
|
||||
"snapshot_reconciliation": reconciliation,
|
||||
"licence_reconciliation": licence_reconciliation,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--target-url", default="http://127.0.0.1:8010")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = import_history(args.source_url, args.target_url, dry_run=args.dry_run)
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(f"Import failed: {exc}") from exc
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
if not result["ok"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
103
tests/test_import_state_hub.py
Normal file
103
tests/test_import_state_hub.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sbom_nexus import importer
|
||||
|
||||
|
||||
def test_import_reconciles_history_licences_and_is_idempotent(monkeypatch) -> None:
|
||||
target_snapshots: list[dict[str, Any]] = []
|
||||
groups = [
|
||||
{
|
||||
"license_spdx": "MIT",
|
||||
"count": 1,
|
||||
"repos": ["demo"],
|
||||
"is_copyleft": False,
|
||||
},
|
||||
{
|
||||
"license_spdx": None,
|
||||
"count": 2,
|
||||
"repos": ["zeta", "demo"],
|
||||
"is_copyleft": False,
|
||||
},
|
||||
]
|
||||
|
||||
def fake_request(
|
||||
base_url: str,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
if base_url == "source":
|
||||
if path == "/repos/":
|
||||
return [
|
||||
{
|
||||
"id": "repo-id",
|
||||
"slug": "demo",
|
||||
"status": "active",
|
||||
"local_path": "/repos/demo",
|
||||
}
|
||||
]
|
||||
if path == "/sbom/snapshots/":
|
||||
return [
|
||||
{
|
||||
"id": "snapshot-id",
|
||||
"repo_id": "repo-id",
|
||||
"snapshot_at": "2026-01-02T03:04:05Z",
|
||||
"source": "manual",
|
||||
"entry_count": 1,
|
||||
}
|
||||
]
|
||||
if path == "/sbom/snapshots/snapshot-id":
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"package_name": "example",
|
||||
"package_version": "1.0",
|
||||
"ecosystem": "other",
|
||||
"license_spdx": "MIT",
|
||||
"is_direct": True,
|
||||
"is_dev": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
if path == "/sbom/report/licences/":
|
||||
return {"groups": list(reversed(groups)), "copyleft_direct_count": 0}
|
||||
if base_url == "target":
|
||||
if method == "PUT":
|
||||
return {"slug": "demo"}
|
||||
if method == "POST" and path == "/sbom/import/":
|
||||
assert body is not None
|
||||
already_present = any(
|
||||
snapshot["legacy_id"] == body["legacy_id"]
|
||||
for snapshot in target_snapshots
|
||||
)
|
||||
if not already_present:
|
||||
target_snapshots.append(
|
||||
{
|
||||
"legacy_id": body["legacy_id"],
|
||||
"repo_slug": body["repo_slug"],
|
||||
"snapshot_at": body["snapshot_at"],
|
||||
"entry_count": len(body["entries"]),
|
||||
}
|
||||
)
|
||||
return {"imported": not already_present}
|
||||
if path == "/sbom/snapshots/":
|
||||
return target_snapshots
|
||||
if path == "/sbom/report/licences/":
|
||||
return {"groups": groups, "copyleft_direct_count": 0}
|
||||
raise AssertionError((base_url, method, path))
|
||||
|
||||
monkeypatch.setattr(importer, "request_json", fake_request)
|
||||
|
||||
first = importer.import_history("source", "target", dry_run=False)
|
||||
second = importer.import_history("source", "target", dry_run=False)
|
||||
|
||||
assert first["ok"] is True
|
||||
assert first["results"] == {"imported": 1}
|
||||
assert first["snapshot_reconciliation"]["matched_snapshot_count"] == 1
|
||||
assert first["licence_reconciliation"]["groups_match"] is True
|
||||
assert second["ok"] is True
|
||||
assert second["results"] == {"already_present": 1}
|
||||
assert len(target_snapshots) == 1
|
||||
38
tests/test_migrations.py
Normal file
38
tests/test_migrations.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
|
||||
def migration_config(database_path: Path) -> Config:
|
||||
config = Config("alembic.ini")
|
||||
config.set_main_option("sqlalchemy.url", f"sqlite:///{database_path}")
|
||||
return config
|
||||
|
||||
|
||||
def test_initial_migration_upgrades_and_downgrades(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "migrated.db"
|
||||
config = migration_config(database_path)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
|
||||
engine = create_engine(f"sqlite:///{database_path}")
|
||||
assert set(inspect(engine).get_table_names()) == {
|
||||
"alembic_version",
|
||||
"entries",
|
||||
"repositories",
|
||||
"snapshots",
|
||||
}
|
||||
assert {index["name"] for index in inspect(engine).get_indexes("entries")} == {
|
||||
"ix_entries_license",
|
||||
"ix_entries_repo",
|
||||
"ix_entries_snapshot",
|
||||
}
|
||||
command.check(config)
|
||||
|
||||
command.downgrade(config, "base")
|
||||
|
||||
assert set(inspect(engine).get_table_names()) == {"alembic_version"}
|
||||
52
tests/test_postgres_integration.py
Normal file
52
tests/test_postgres_integration.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sbom_nexus.api import create_app
|
||||
|
||||
POSTGRES_URL = os.getenv("SBOM_NEXUS_TEST_POSTGRES_URL")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not POSTGRES_URL,
|
||||
reason="SBOM_NEXUS_TEST_POSTGRES_URL is not configured",
|
||||
)
|
||||
|
||||
|
||||
def test_migrated_postgres_runtime_contract() -> None:
|
||||
assert POSTGRES_URL is not None
|
||||
client = TestClient(create_app(POSTGRES_URL))
|
||||
|
||||
assert client.get("/state/health").json() == {
|
||||
"status": "ok",
|
||||
"store": "connected",
|
||||
"dialect": "postgresql",
|
||||
}
|
||||
response = client.put(
|
||||
"/repositories/postgres-contract",
|
||||
json={"active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ingest = client.post(
|
||||
"/sbom/ingest/",
|
||||
json={
|
||||
"repo_slug": "postgres-contract",
|
||||
"entries": [
|
||||
{
|
||||
"package_name": "psycopg",
|
||||
"package_version": "3.2",
|
||||
"ecosystem": "python",
|
||||
"license_spdx": "LGPL-3.0-only",
|
||||
"is_direct": True,
|
||||
"is_dev": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert ingest.status_code == 200
|
||||
assert ingest.json()["ingested"] == 1
|
||||
assert client.get("/sbom/postgres-contract").json()["entry_count"] == 1
|
||||
assert client.get("/sbom/report/licences/").json()["copyleft_direct_count"] == 1
|
||||
303
uv.lock
generated
303
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
105
workplans/SBOM-WP-0002-production-cutover.md
Normal file
105
workplans/SBOM-WP-0002-production-cutover.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
id: SBOM-WP-0002
|
||||
type: workplan
|
||||
title: "Deploy and cut over SBOM Nexus production authority"
|
||||
domain: infotech
|
||||
repo: sbom-nexus
|
||||
status: ready
|
||||
owner: codex
|
||||
topic_slug: infotech
|
||||
created: "2026-08-22"
|
||||
updated: "2026-08-22"
|
||||
parent_workplan: CUST-WP-0062
|
||||
related:
|
||||
- SBOM-WP-0001
|
||||
- CUST-WP-0062
|
||||
- ACTIVITY-WP-0030
|
||||
- STATE-WP-0079
|
||||
- RMGR-WP-0008
|
||||
---
|
||||
|
||||
# Deploy and cut over SBOM Nexus production authority
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy SBOM Nexus with managed PostgreSQL, import and reconcile State Hub
|
||||
history, move callers through reversible compatibility stages, and prove the
|
||||
bounded daily catch-up before retiring State Hub SBOM ownership.
|
||||
|
||||
## Deploy dark with managed PostgreSQL
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T01
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Provision database credentials through the governed route, migrate schema,
|
||||
deploy the API without callers, and capture health plus backup/restore evidence.
|
||||
|
||||
## Synchronize repository projections
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T02
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Populate active repository identity and host checkout paths from Repo Manager.
|
||||
Verify fleet totals and catch-up ordering without performing ingest.
|
||||
|
||||
## Import and reconcile State Hub history
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T03
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
Depends on T01/T02. Back up the empty target, run the idempotent importer, and
|
||||
retain an exact reconciliation report before any caller switch.
|
||||
|
||||
## Cut over State Hub compatibility façade
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T04
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
Depends on T03 and the State Hub child change. Move reads then writes behind
|
||||
reversible flags; retarget dashboard, MCP, CLI, summary, DoI, and onboarding.
|
||||
|
||||
## Retarget Repo Manager scanner interface
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T05
|
||||
status: wait
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Depends on dark deployment. Preserve CLI usability while removing competing
|
||||
SBOM product authority and pinning the Nexus contract.
|
||||
|
||||
## Enable bounded Activity Core ingest
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T06
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
Depends on T03/T04 and `ACTIVITY-WP-0030`. Enable no more than N ingests/skips
|
||||
per fire with zero spawned catch-up tasks.
|
||||
|
||||
## Stabilize and retire legacy ownership
|
||||
|
||||
```task
|
||||
id: SBOM-WP-0002-T07
|
||||
status: wait
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Capture two successful daily fires and a zero-flood Monday window. Record the
|
||||
retention decision, then retire State Hub SBOM ownership after the stabilization
|
||||
window without deleting historical data implicitly.
|
||||
Loading…
Add table
Add a link
Reference in a new issue