generated from coulomb/repo-seed
WP-0001-T002: registry data model, Alembic, initial migration with retention seed
Schema (src/artifactstore/db/schema.py): - events table (ADR-0002 source of truth): sequence BIGSERIAL PK, created_at, event_type, subject_kind, subject_id, actor, payload (CBOR bytes), payload_digest. Indexes on (subject_kind, subject_id) and (event_type, sequence). - artifact_packages, artifact_files, storage_locations, retention_state (materialised views over events). - retention_classes (seed table) and metadata_schemas (config table). - ADR-0001 columns present: digest_algorithm, digest_primary, digest_sha256, content_address. Blueprint tiering columns present: retrieval_tier (default 'hot'), restore_status. - Types portable: SQLAlchemy 2.0 Core with JSON().with_variant(JSONB, 'postgresql'), Uuid, LargeBinary, DateTime(timezone=True), Boolean false() default. Seed (src/artifactstore/db/seed.py): five v1 retention classes (transient, raw-evidence, summary-evidence, release-evidence, permanent-record) with default durations in seconds; permanent-record has no expiry. Alembic: - alembic.ini with sync sqlite URL default; path_separator=os to silence the 1.13 deprecation warning. - migrations/env.py: translates async URLs (+aiosqlite, +asyncpg) to sync counterparts at migrate-time so a single ARTIFACTSTORE_DATABASE_URL works for both runtime (async) and Alembic (sync). - migrations/script.py.mako template. - migrations/versions/20260516_0001_initial.py: metadata.create_all + bulk insert of retention class seeds. Make: - make migrate: alembic upgrade head (ensures var/ exists). - make migrate-fresh: drop local SQLite + re-run. Deps: psycopg[binary] added as optional `postgres` extra (PostgreSQL prod path; SQLite default for dev needs no extra). Tests: - tests/unit/test_db_schema.py: every expected table present; ADR-0001 and tiering columns present; seed has the five v1 classes; permanent-record has no default_duration; create_all + FK insert + Boolean default round-trip on in-memory SQLite. - tests/integration/test_migrations.py: alembic upgrade head against a tempfile SQLite produces all tables (+ alembic_version) and the seed rows. Gates: ruff clean, mypy --strict clean on 32 files, 38 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
31
Makefile
31
Makefile
@@ -1,15 +1,16 @@
|
||||
.PHONY: help install dev test lint format type migrate clean
|
||||
.PHONY: help install dev test lint format type migrate migrate-fresh clean
|
||||
|
||||
help:
|
||||
@echo "artifact-store — make targets"
|
||||
@echo " install install / sync dependencies via uv"
|
||||
@echo " dev run the FastAPI app with reload (uvicorn)"
|
||||
@echo " test run the pytest suite"
|
||||
@echo " lint ruff check + ruff format --check"
|
||||
@echo " format ruff format (write changes)"
|
||||
@echo " type mypy --strict over src and tests"
|
||||
@echo " migrate alembic upgrade head (configured by WP-0001-T002)"
|
||||
@echo " clean remove caches and build artefacts"
|
||||
@echo " install install / sync dependencies via uv"
|
||||
@echo " dev run the FastAPI app with reload (uvicorn)"
|
||||
@echo " test run the pytest suite"
|
||||
@echo " lint ruff check + ruff format --check"
|
||||
@echo " format ruff format (write changes)"
|
||||
@echo " type mypy --strict over src and tests"
|
||||
@echo " migrate alembic upgrade head"
|
||||
@echo " migrate-fresh drop the local SQLite DB and re-run migrations"
|
||||
@echo " clean remove caches and build artefacts"
|
||||
|
||||
install:
|
||||
uv sync --all-extras
|
||||
@@ -32,11 +33,13 @@ type:
|
||||
uv run mypy
|
||||
|
||||
migrate:
|
||||
@if [ -f alembic.ini ]; then \
|
||||
uv run alembic upgrade head; \
|
||||
else \
|
||||
echo "alembic.ini not present yet — see ARTIFACT-STORE-WP-0001-T002"; \
|
||||
fi
|
||||
@mkdir -p var
|
||||
uv run alembic upgrade head
|
||||
|
||||
migrate-fresh:
|
||||
@rm -f var/artifactstore.db var/artifactstore.db-journal
|
||||
@mkdir -p var
|
||||
uv run alembic upgrade head
|
||||
|
||||
clean:
|
||||
rm -rf .pytest_cache .mypy_cache .ruff_cache build dist *.egg-info
|
||||
|
||||
44
alembic.ini
Normal file
44
alembic.ini
Normal file
@@ -0,0 +1,44 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite:///./var/artifactstore.db
|
||||
timezone = UTC
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
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
|
||||
73
migrations/env.py
Normal file
73
migrations/env.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Alembic environment configuration.
|
||||
|
||||
The migration runner is sync; the runtime service is async. To support a
|
||||
single configured ``ARTIFACTSTORE_DATABASE_URL``, this module rewrites
|
||||
async driver URLs (``+aiosqlite``, ``+asyncpg``) to their sync counterparts
|
||||
when invoking Alembic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SRC = _ROOT / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from artifactstore.config import get_settings # noqa: E402
|
||||
from artifactstore.db.schema import metadata as target_metadata # noqa: E402
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def _sync_url(url: str) -> str:
|
||||
"""Translate an async driver URL to its sync counterpart for Alembic."""
|
||||
if "+aiosqlite" in url:
|
||||
return url.replace("+aiosqlite", "")
|
||||
if "+asyncpg" in url:
|
||||
return url.replace("+asyncpg", "+psycopg")
|
||||
return url
|
||||
|
||||
|
||||
_settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", _sync_url(_settings.database_url))
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL without a live DB connection."""
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live DB connection."""
|
||||
section = config.get_section(config.config_ini_section) or {}
|
||||
connectable = engine_from_config(
|
||||
section,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
27
migrations/script.py.mako
Normal file
27
migrations/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
39
migrations/versions/20260516_0001_initial.py
Normal file
39
migrations/versions/20260516_0001_initial.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""initial schema (events + materialised views + retention seed).
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from artifactstore.db.schema import metadata
|
||||
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
metadata.create_all(bind=bind)
|
||||
|
||||
retention_classes_lt = sa.table(
|
||||
"retention_classes",
|
||||
sa.column("class_id", sa.String),
|
||||
sa.column("default_duration_seconds", sa.BigInteger),
|
||||
sa.column("deletion_strategy", sa.String),
|
||||
)
|
||||
op.bulk_insert(retention_classes_lt, [dict(s) for s in RETENTION_CLASS_SEEDS])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
metadata.drop_all(bind=bind)
|
||||
@@ -45,6 +45,9 @@ dev = [
|
||||
"ruff >= 0.6",
|
||||
"mypy >= 1.10",
|
||||
]
|
||||
postgres = [
|
||||
"psycopg[binary] >= 3.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
artifactstore = "artifactstore.cli:app"
|
||||
|
||||
12
src/artifactstore/db/__init__.py
Normal file
12
src/artifactstore/db/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Database schema and engine factory.
|
||||
|
||||
The ``schema`` submodule owns the SQLAlchemy Core :class:`MetaData` and
|
||||
:class:`Table` definitions referenced by both migrations and runtime queries.
|
||||
``engine`` exposes the async engine factory. ``seed`` holds bootstrap data
|
||||
applied by the initial migration.
|
||||
"""
|
||||
|
||||
from artifactstore.db import schema, seed
|
||||
from artifactstore.db.engine import create_engine
|
||||
|
||||
__all__ = ["create_engine", "schema", "seed"]
|
||||
12
src/artifactstore/db/engine.py
Normal file
12
src/artifactstore/db/engine.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Async SQLAlchemy engine factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from artifactstore.config import Settings
|
||||
|
||||
|
||||
def create_engine(settings: Settings) -> AsyncEngine:
|
||||
"""Construct the runtime async engine from settings."""
|
||||
return create_async_engine(settings.database_url, echo=False, future=True)
|
||||
160
src/artifactstore/db/schema.py
Normal file
160
src/artifactstore/db/schema.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Database schema (ADR-0002 + ARCHITECTURE-BLUEPRINT data model).
|
||||
|
||||
All tables are defined via SQLAlchemy Core so the same definitions drive
|
||||
migrations (Alembic) and runtime queries (registry orchestrator). Types use
|
||||
the portable SQLAlchemy 2.0 forms; PostgreSQL-specific variants are layered
|
||||
via :func:`with_variant` where the gain (e.g. ``JSONB`` over ``JSON``) is
|
||||
meaningful.
|
||||
|
||||
The ``events`` table is the source of truth (ADR-0002). The other tables
|
||||
are materialised views rebuildable from the event log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
LargeBinary,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import false
|
||||
from sqlalchemy.types import Uuid
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
events = Table(
|
||||
"events",
|
||||
metadata,
|
||||
Column("sequence", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("event_type", String, nullable=False),
|
||||
Column("subject_kind", String, nullable=False),
|
||||
Column("subject_id", Uuid, nullable=True),
|
||||
Column("actor", String, nullable=False),
|
||||
Column("payload", LargeBinary, nullable=False),
|
||||
Column("payload_digest", LargeBinary, nullable=False),
|
||||
Index("ix_events_subject", "subject_kind", "subject_id"),
|
||||
Index("ix_events_type_sequence", "event_type", "sequence"),
|
||||
)
|
||||
|
||||
|
||||
retention_classes = Table(
|
||||
"retention_classes",
|
||||
metadata,
|
||||
Column("class_id", String, primary_key=True),
|
||||
Column("default_duration_seconds", BigInteger, nullable=True),
|
||||
Column("deletion_strategy", String, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
metadata_schemas = Table(
|
||||
"metadata_schemas",
|
||||
metadata,
|
||||
Column("id", Uuid, primary_key=True),
|
||||
Column("slug", String, nullable=False, unique=True),
|
||||
Column("json_schema", _JSON_TYPE, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
|
||||
artifact_packages = Table(
|
||||
"artifact_packages",
|
||||
metadata,
|
||||
Column("id", Uuid, primary_key=True),
|
||||
Column("name", String, nullable=False),
|
||||
Column("producer", String, nullable=False),
|
||||
Column("subject", String, nullable=False),
|
||||
Column(
|
||||
"retention_class",
|
||||
String,
|
||||
ForeignKey("retention_classes.class_id"),
|
||||
nullable=False,
|
||||
),
|
||||
Column(
|
||||
"metadata_schema_id",
|
||||
Uuid,
|
||||
ForeignKey("metadata_schemas.id"),
|
||||
nullable=True,
|
||||
),
|
||||
Column("metadata", _JSON_TYPE, nullable=False),
|
||||
Column("status", String, nullable=False),
|
||||
Column("manifest_digest", LargeBinary, nullable=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("finalized_at", DateTime(timezone=True), nullable=True),
|
||||
Column("expires_at", DateTime(timezone=True), nullable=True),
|
||||
Column("last_event_sequence", BigInteger, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
artifact_files = Table(
|
||||
"artifact_files",
|
||||
metadata,
|
||||
Column("id", Uuid, primary_key=True),
|
||||
Column(
|
||||
"package_id",
|
||||
Uuid,
|
||||
ForeignKey("artifact_packages.id"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("relative_path", String, nullable=False),
|
||||
Column("media_type", String, nullable=False),
|
||||
Column("size_bytes", BigInteger, nullable=False),
|
||||
Column("digest_algorithm", String, nullable=False),
|
||||
Column("digest_primary", LargeBinary, nullable=False),
|
||||
Column("digest_sha256", LargeBinary, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("package_id", "relative_path", name="uq_artifact_files_pkg_path"),
|
||||
)
|
||||
|
||||
|
||||
storage_locations = Table(
|
||||
"storage_locations",
|
||||
metadata,
|
||||
Column("id", Uuid, primary_key=True),
|
||||
Column(
|
||||
"artifact_file_id",
|
||||
Uuid,
|
||||
ForeignKey("artifact_files.id"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("backend_id", String, nullable=False),
|
||||
Column("content_address", String, nullable=False),
|
||||
Column("object_key", String, nullable=False),
|
||||
Column("storage_class", String, nullable=True),
|
||||
Column("retrieval_tier", String, nullable=False, server_default="hot"),
|
||||
Column("restore_status", String, nullable=True),
|
||||
Column("status", String, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("last_verified_at", DateTime(timezone=True), nullable=True),
|
||||
Index("ix_storage_locations_content_address", "content_address"),
|
||||
)
|
||||
|
||||
|
||||
retention_state = Table(
|
||||
"retention_state",
|
||||
metadata,
|
||||
Column(
|
||||
"package_id",
|
||||
Uuid,
|
||||
ForeignKey("artifact_packages.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column("current_expires_at", DateTime(timezone=True), nullable=True),
|
||||
Column("effective_class", String, nullable=False),
|
||||
Column("active_hold_id", Uuid, nullable=True),
|
||||
Column("eligible_for_deletion", Boolean, nullable=False, server_default=false()),
|
||||
)
|
||||
52
src/artifactstore/db/seed.py
Normal file
52
src/artifactstore/db/seed.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Bootstrap seed data applied by the initial migration.
|
||||
|
||||
The :data:`RETENTION_CLASS_SEEDS` entries match the five v1 retention classes
|
||||
listed in ``docs/ARCHITECTURE-BLUEPRINT.md``. Default durations are intended
|
||||
to be overridable by an operator configuration file (WP-0003); the seed
|
||||
values only ensure the registry has sensible defaults on a fresh DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class RetentionClassSeed(TypedDict):
|
||||
class_id: str
|
||||
default_duration_seconds: int | None
|
||||
deletion_strategy: str
|
||||
|
||||
|
||||
_ONE_DAY = 86_400
|
||||
_NINETY_DAYS = 90 * _ONE_DAY
|
||||
_ONE_YEAR = 365 * _ONE_DAY
|
||||
_SEVEN_YEARS = 7 * _ONE_YEAR
|
||||
|
||||
|
||||
RETENTION_CLASS_SEEDS: tuple[RetentionClassSeed, ...] = (
|
||||
{
|
||||
"class_id": "transient",
|
||||
"default_duration_seconds": _ONE_DAY,
|
||||
"deletion_strategy": "mark_eligible",
|
||||
},
|
||||
{
|
||||
"class_id": "raw-evidence",
|
||||
"default_duration_seconds": _NINETY_DAYS,
|
||||
"deletion_strategy": "mark_eligible",
|
||||
},
|
||||
{
|
||||
"class_id": "summary-evidence",
|
||||
"default_duration_seconds": _ONE_YEAR,
|
||||
"deletion_strategy": "mark_eligible",
|
||||
},
|
||||
{
|
||||
"class_id": "release-evidence",
|
||||
"default_duration_seconds": _SEVEN_YEARS,
|
||||
"deletion_strategy": "mark_eligible",
|
||||
},
|
||||
{
|
||||
"class_id": "permanent-record",
|
||||
"default_duration_seconds": None,
|
||||
"deletion_strategy": "mark_eligible",
|
||||
},
|
||||
)
|
||||
58
tests/integration/test_migrations.py
Normal file
58
tests/integration/test_migrations.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""End-to-end migration test against a temporary SQLite DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, select
|
||||
|
||||
from artifactstore.db.schema import retention_classes
|
||||
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
db_path = tmp_path / "artifactstore-test.db"
|
||||
url = f"sqlite+aiosqlite:///{db_path}"
|
||||
monkeypatch.setenv("ARTIFACTSTORE_DATABASE_URL", url)
|
||||
return url
|
||||
|
||||
|
||||
def test_alembic_upgrade_head_creates_all_tables_and_seeds(temp_db_url: str) -> None:
|
||||
cfg = Config(str(REPO_ROOT / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(REPO_ROOT / "migrations"))
|
||||
|
||||
cwd = os.getcwd()
|
||||
os.chdir(REPO_ROOT)
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
|
||||
sync_url = temp_db_url.replace("+aiosqlite", "")
|
||||
engine = create_engine(sync_url, future=True)
|
||||
try:
|
||||
names = set(inspect(engine).get_table_names())
|
||||
assert {
|
||||
"events",
|
||||
"retention_classes",
|
||||
"metadata_schemas",
|
||||
"artifact_packages",
|
||||
"artifact_files",
|
||||
"storage_locations",
|
||||
"retention_state",
|
||||
"alembic_version",
|
||||
}.issubset(names)
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(select(retention_classes)).all()
|
||||
seed_ids = {s["class_id"] for s in RETENTION_CLASS_SEEDS}
|
||||
assert {r.class_id for r in rows} == seed_ids
|
||||
finally:
|
||||
engine.dispose()
|
||||
180
tests/unit/test_db_schema.py
Normal file
180
tests/unit/test_db_schema.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Schema definition tests (ARTIFACT-STORE-WP-0001-T002).
|
||||
|
||||
Verifies that the SQLAlchemy metadata exposes every table named in the
|
||||
architecture blueprint, with the columns required by ADR-0001 / ADR-0002,
|
||||
plus a working create_all + seed insert against an in-memory SQLite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, insert, select
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from artifactstore.db.schema import (
|
||||
artifact_files,
|
||||
artifact_packages,
|
||||
events,
|
||||
metadata,
|
||||
metadata_schemas,
|
||||
retention_classes,
|
||||
retention_state,
|
||||
storage_locations,
|
||||
)
|
||||
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"events",
|
||||
"retention_classes",
|
||||
"metadata_schemas",
|
||||
"artifact_packages",
|
||||
"artifact_files",
|
||||
"storage_locations",
|
||||
"retention_state",
|
||||
}
|
||||
|
||||
|
||||
def test_all_expected_tables_present() -> None:
|
||||
assert EXPECTED_TABLES.issubset(metadata.tables.keys())
|
||||
|
||||
|
||||
def test_events_table_columns() -> None:
|
||||
cols = {c.name for c in events.columns}
|
||||
assert {
|
||||
"sequence",
|
||||
"created_at",
|
||||
"event_type",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"actor",
|
||||
"payload",
|
||||
"payload_digest",
|
||||
}.issubset(cols)
|
||||
|
||||
|
||||
def test_artifact_files_carries_adr_0001_columns() -> None:
|
||||
cols = {c.name for c in artifact_files.columns}
|
||||
assert {
|
||||
"digest_algorithm",
|
||||
"digest_primary",
|
||||
"digest_sha256",
|
||||
}.issubset(cols)
|
||||
|
||||
|
||||
def test_storage_locations_carries_content_address_and_tiering() -> None:
|
||||
cols = {c.name for c in storage_locations.columns}
|
||||
assert {
|
||||
"content_address",
|
||||
"retrieval_tier",
|
||||
"restore_status",
|
||||
}.issubset(cols)
|
||||
|
||||
|
||||
def test_metadata_schemas_table_present() -> None:
|
||||
cols = {c.name for c in metadata_schemas.columns}
|
||||
assert {"id", "slug", "json_schema"}.issubset(cols)
|
||||
|
||||
|
||||
def test_retention_classes_seed_has_five_v1_entries() -> None:
|
||||
class_ids = {s["class_id"] for s in RETENTION_CLASS_SEEDS}
|
||||
assert class_ids == {
|
||||
"transient",
|
||||
"raw-evidence",
|
||||
"summary-evidence",
|
||||
"release-evidence",
|
||||
"permanent-record",
|
||||
}
|
||||
|
||||
|
||||
def test_retention_classes_permanent_record_has_no_default_duration() -> None:
|
||||
perm = next(s for s in RETENTION_CLASS_SEEDS if s["class_id"] == "permanent-record")
|
||||
assert perm["default_duration_seconds"] is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_memory_engine() -> Engine:
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def test_create_all_on_sqlite_produces_expected_tables(in_memory_engine: Engine) -> None:
|
||||
with in_memory_engine.connect() as conn:
|
||||
inspector_rows = conn.execute(
|
||||
select(events).limit(0) # forces table reference resolution
|
||||
)
|
||||
# consume to ensure no error
|
||||
inspector_rows.close()
|
||||
table_names = set(metadata.tables.keys())
|
||||
assert EXPECTED_TABLES.issubset(table_names)
|
||||
|
||||
|
||||
def test_seed_round_trip_through_sqlite(in_memory_engine: Engine) -> None:
|
||||
with in_memory_engine.begin() as conn:
|
||||
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
|
||||
with in_memory_engine.connect() as conn:
|
||||
rows = conn.execute(select(retention_classes)).all()
|
||||
assert len(rows) == len(RETENTION_CLASS_SEEDS)
|
||||
class_ids = {r.class_id for r in rows}
|
||||
assert class_ids == {s["class_id"] for s in RETENTION_CLASS_SEEDS}
|
||||
|
||||
|
||||
def test_artifact_package_fk_to_retention_classes(in_memory_engine: Engine) -> None:
|
||||
with in_memory_engine.begin() as conn:
|
||||
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
|
||||
pkg_id = uuid.uuid4()
|
||||
conn.execute(
|
||||
insert(artifact_packages).values(
|
||||
id=pkg_id,
|
||||
name="t",
|
||||
producer="t",
|
||||
subject="t",
|
||||
retention_class="raw-evidence",
|
||||
metadata_schema_id=None,
|
||||
metadata={},
|
||||
status="created",
|
||||
manifest_digest=None,
|
||||
last_event_sequence=1,
|
||||
)
|
||||
)
|
||||
with in_memory_engine.connect() as conn:
|
||||
rows = conn.execute(select(artifact_packages).where(artifact_packages.c.id == pkg_id)).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].retention_class == "raw-evidence"
|
||||
|
||||
|
||||
def test_retention_state_default_eligible_for_deletion_is_false(
|
||||
in_memory_engine: Engine,
|
||||
) -> None:
|
||||
with in_memory_engine.begin() as conn:
|
||||
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
|
||||
pkg_id = uuid.uuid4()
|
||||
conn.execute(
|
||||
insert(artifact_packages).values(
|
||||
id=pkg_id,
|
||||
name="t",
|
||||
producer="t",
|
||||
subject="t",
|
||||
retention_class="raw-evidence",
|
||||
metadata_schema_id=None,
|
||||
metadata={},
|
||||
status="created",
|
||||
manifest_digest=None,
|
||||
last_event_sequence=1,
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
insert(retention_state).values(
|
||||
package_id=pkg_id,
|
||||
current_expires_at=None,
|
||||
effective_class="raw-evidence",
|
||||
active_hold_id=None,
|
||||
)
|
||||
)
|
||||
with in_memory_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(retention_state).where(retention_state.c.package_id == pkg_id)
|
||||
).one()
|
||||
assert row.eligible_for_deletion is False
|
||||
71
uv.lock
generated
71
uv.lock
generated
@@ -88,6 +88,9 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
postgres = [
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -111,6 +114,7 @@ requires-dist = [
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" },
|
||||
{ name = "jcs", specifier = ">=0.2" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" },
|
||||
{ name = "pydantic", specifier = ">=2.7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.4" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
@@ -752,6 +756,64 @@ 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/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"
|
||||
@@ -1113,6 +1175,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.47.0"
|
||||
|
||||
@@ -143,7 +143,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: ARTIFACT-STORE-WP-0001-T002
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e5249a39-46a2-4b56-813e-0339c52cd14e"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user