diff --git a/Makefile b/Makefile index 9d8f198..c7de1ba 100644 --- a/Makefile +++ b/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 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5177a42 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..800ef96 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..75a9862 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/20260516_0001_initial.py b/migrations/versions/20260516_0001_initial.py new file mode 100644 index 0000000..f6287b2 --- /dev/null +++ b/migrations/versions/20260516_0001_initial.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 94f3bb8..24fcafc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ dev = [ "ruff >= 0.6", "mypy >= 1.10", ] +postgres = [ + "psycopg[binary] >= 3.2", +] [project.scripts] artifactstore = "artifactstore.cli:app" diff --git a/src/artifactstore/db/__init__.py b/src/artifactstore/db/__init__.py new file mode 100644 index 0000000..dc5158b --- /dev/null +++ b/src/artifactstore/db/__init__.py @@ -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"] diff --git a/src/artifactstore/db/engine.py b/src/artifactstore/db/engine.py new file mode 100644 index 0000000..8a30abf --- /dev/null +++ b/src/artifactstore/db/engine.py @@ -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) diff --git a/src/artifactstore/db/schema.py b/src/artifactstore/db/schema.py new file mode 100644 index 0000000..bf143a1 --- /dev/null +++ b/src/artifactstore/db/schema.py @@ -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()), +) diff --git a/src/artifactstore/db/seed.py b/src/artifactstore/db/seed.py new file mode 100644 index 0000000..d224bb7 --- /dev/null +++ b/src/artifactstore/db/seed.py @@ -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", + }, +) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..39c37c9 --- /dev/null +++ b/tests/integration/test_migrations.py @@ -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() diff --git a/tests/unit/test_db_schema.py b/tests/unit/test_db_schema.py new file mode 100644 index 0000000..8d30e0d --- /dev/null +++ b/tests/unit/test_db_schema.py @@ -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 diff --git a/uv.lock b/uv.lock index 8d5348f..c5d3535 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/workplans/ARTIFACT-STORE-WP-0001-service-baseline.md b/workplans/ARTIFACT-STORE-WP-0001-service-baseline.md index 3eca839..0176c8d 100644 --- a/workplans/ARTIFACT-STORE-WP-0001-service-baseline.md +++ b/workplans/ARTIFACT-STORE-WP-0001-service-baseline.md @@ -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" ```