From 5f47da8036a7872d188a2ae0a03dc124d7e9aa36 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 20:23:25 +0200 Subject: [PATCH] CANP-WP-0006 T03: read API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search, versions, manifests, archives and the index, over HTTP. The route shape is the decision worth recording. Package ids contain `/`, so the obvious /packages/{id}/{version} is ambiguous under a greedy path parameter. Rather than invent an HTTP-specific identifier, the routes speak the format's own :@ syntax and parse it — `:` and `@` are both legal in a path segment, and each route keeps a distinct prefix so greediness cannot swallow a neighbouring one. The API therefore exercises section 3.2's reference notation instead of working around it. A bare id present in more than one registry returns 409 with the candidates, never a guess. 409 rather than 300 because the request is answerable once the caller says which registry they meant. Omitting a version applies section 17.1's selector rules, so a prerelease is never chosen implicitly. Validation is delegated to reference/, installed into the service environment rather than reimplemented. One validator means the service and the CLI cannot disagree about what a valid package is; a service accepting something the CLI rejects would be the divergence this project exists to prevent. Importing it is not changing it — reference/ stays the dependency-light conformance witness. Storage keeps the format's distinctions: an immutable package version, its files as content rather than parsed rows, and an index entry recording arrival. Only reserved paths and manifest-referenced files are stored (section 2), and a re-publish of identical content is accepted while different content under the same id@version is a conflict (section 17). Handles a real test-vs-production difference: SQLite autoincrements INTEGER PRIMARY KEY only, never BIGINT, so the SQLite-backed tests could not insert a row. BigInteger().with_variant(Integer, "sqlite") keeps BIGINT on PostgreSQL while letting the tests exercise the same models and migration. Verified live against a seeded store holding this repo's examples and four helix-forge prompt packages. Service tests 11 -> 22. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502 --- .../PKG-INFO | 6 + .../SOURCES.txt | 10 + .../dependency_links.txt | 1 + .../entry_points.txt | 2 + .../requires.txt | 1 + .../top_level.txt | 1 + service/README.md | 30 ++- .../0001_tenant_keyed_package_store.py | 10 +- service/src/canned_prompts_service/api.py | 16 +- service/src/canned_prompts_service/models.py | 13 +- service/src/canned_prompts_service/routes.py | 136 ++++++++++ service/src/canned_prompts_service/store.py | 250 ++++++++++++++++++ service/tests/conftest.py | 63 +++++ service/tests/test_read_api.py | 87 ++++++ .../CANP-WP-0006-hosted-registry-service.md | 26 +- 15 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 reference/canned_prompts_reference.egg-info/PKG-INFO create mode 100644 reference/canned_prompts_reference.egg-info/SOURCES.txt create mode 100644 reference/canned_prompts_reference.egg-info/dependency_links.txt create mode 100644 reference/canned_prompts_reference.egg-info/entry_points.txt create mode 100644 reference/canned_prompts_reference.egg-info/requires.txt create mode 100644 reference/canned_prompts_reference.egg-info/top_level.txt create mode 100644 service/src/canned_prompts_service/routes.py create mode 100644 service/src/canned_prompts_service/store.py create mode 100644 service/tests/conftest.py create mode 100644 service/tests/test_read_api.py diff --git a/reference/canned_prompts_reference.egg-info/PKG-INFO b/reference/canned_prompts_reference.egg-info/PKG-INFO new file mode 100644 index 0000000..867e700 --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/PKG-INFO @@ -0,0 +1,6 @@ +Metadata-Version: 2.4 +Name: canned-prompts-reference +Version: 0.1.0 +Summary: Tiny filesystem reference CLI for Canned Prompt Format v0.1 +Requires-Python: >=3.10 +Requires-Dist: PyYAML<7,>=6.0 diff --git a/reference/canned_prompts_reference.egg-info/SOURCES.txt b/reference/canned_prompts_reference.egg-info/SOURCES.txt new file mode 100644 index 0000000..3138d3d --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/SOURCES.txt @@ -0,0 +1,10 @@ +README.md +canned_prompts.py +pyproject.toml +canned_prompts_reference.egg-info/PKG-INFO +canned_prompts_reference.egg-info/SOURCES.txt +canned_prompts_reference.egg-info/dependency_links.txt +canned_prompts_reference.egg-info/entry_points.txt +canned_prompts_reference.egg-info/requires.txt +canned_prompts_reference.egg-info/top_level.txt +tests/test_canned_prompts.py \ No newline at end of file diff --git a/reference/canned_prompts_reference.egg-info/dependency_links.txt b/reference/canned_prompts_reference.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/reference/canned_prompts_reference.egg-info/entry_points.txt b/reference/canned_prompts_reference.egg-info/entry_points.txt new file mode 100644 index 0000000..5ee6fe8 --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +canned-prompts = canned_prompts:main diff --git a/reference/canned_prompts_reference.egg-info/requires.txt b/reference/canned_prompts_reference.egg-info/requires.txt new file mode 100644 index 0000000..2f2a2a7 --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/requires.txt @@ -0,0 +1 @@ +PyYAML<7,>=6.0 diff --git a/reference/canned_prompts_reference.egg-info/top_level.txt b/reference/canned_prompts_reference.egg-info/top_level.txt new file mode 100644 index 0000000..4ce28c5 --- /dev/null +++ b/reference/canned_prompts_reference.egg-info/top_level.txt @@ -0,0 +1 @@ +canned_prompts diff --git a/service/README.md b/service/README.md index f3ca9d8..3c03ed4 100644 --- a/service/README.md +++ b/service/README.md @@ -66,8 +66,32 @@ hold the same id. | `package_files` | the package's files as content, not parsed into rows | | `index_entries` | how a version arrived here (§ 20.3): source, method, `included_at` never overwritten, `last_seen_at` | +## Read API + +Routes address packages with the format's own reference syntax +(`:@`, § 3.2 and § 17) rather than a second one invented +for HTTP. A package id contains `/`, so the reference is captured as a greedy +path and parsed; each route keeps a distinct prefix so the greediness cannot +swallow a neighbouring segment. + +| Route | Returns | +|---|---| +| `GET /packages?q=®istry=` | search over id, name, summary and tags | +| `GET /packages/{id}` | the versions of an id | +| `GET /packages/{id}@{version}` | one manifest | +| `GET /archives/{id}@{version}` | the package's files, text or base64 | +| `GET /index` | § 20.3 entries: source, method, `included_at`, `last_seen_at` | + +A bare id present in more than one registry returns **409 with the candidates**, +never a guess (§ 3.2). 409 rather than 300 because the request is answerable — +once the caller says which registry they meant. + +Asking for a package without naming a version applies § 17.1's selector rules, +so a prerelease is never chosen implicitly. + ## Status -`CANP-WP-0006` T01 and T02 are done: skeleton, health surface, tenant-keyed -schema and migration `0001`. The read API, publish API, HTTP registry client and -container image (T03–T06) are not built yet. +`CANP-WP-0006` T01–T03 are done: skeleton, health surface, tenant-keyed schema +and migration `0001`, and the read API. The publish API, HTTP registry client +and container image (T04–T06) are not built yet. T06 produces the image digest +that `rapp-canned-prompts` needs to pin. diff --git a/service/migrations/versions/0001_tenant_keyed_package_store.py b/service/migrations/versions/0001_tenant_keyed_package_store.py index caab49d..152ca89 100644 --- a/service/migrations/versions/0001_tenant_keyed_package_store.py +++ b/service/migrations/versions/0001_tenant_keyed_package_store.py @@ -11,6 +11,10 @@ Revises: from alembic import op import sqlalchemy as sa +# Matches models.BigIntPK: BIGINT on PostgreSQL, INTEGER on SQLite so the +# test database autoincrements the same way production does. +BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite") + revision = '0001' down_revision = None branch_labels = None @@ -20,7 +24,7 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table('index_entries', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('id', BigIntPK, autoincrement=True, nullable=False), sa.Column('tenant', sa.String(length=64), nullable=False), sa.Column('registry', sa.String(length=128), nullable=False), sa.Column('package_id', sa.String(length=512), nullable=False), @@ -36,7 +40,7 @@ def upgrade() -> None: sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_index_entry') ) op.create_table('package_versions', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('id', BigIntPK, autoincrement=True, nullable=False), sa.Column('tenant', sa.String(length=64), nullable=False), sa.Column('registry', sa.String(length=128), nullable=False), sa.Column('package_id', sa.String(length=512), nullable=False), @@ -54,7 +58,7 @@ def upgrade() -> None: ) op.create_index('ix_package_versions_tenant_id', 'package_versions', ['tenant', 'package_id'], unique=False) op.create_table('package_files', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('id', BigIntPK, autoincrement=True, nullable=False), sa.Column('tenant', sa.String(length=64), nullable=False), sa.Column('package_version_id', sa.BigInteger(), nullable=False), sa.Column('path', sa.String(length=1024), nullable=False), diff --git a/service/src/canned_prompts_service/api.py b/service/src/canned_prompts_service/api.py index c51bf65..0566a9c 100644 --- a/service/src/canned_prompts_service/api.py +++ b/service/src/canned_prompts_service/api.py @@ -16,7 +16,8 @@ from fastapi import FastAPI, Response from sqlalchemy.engine import Engine from . import __version__ -from .db import check_readiness, make_engine +from .db import check_readiness, make_engine, make_session_factory +from .routes import make_router from .settings import Settings, get_settings @@ -28,6 +29,18 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) - app = FastAPI(title="canned-prompts registry", version=__version__) app.state.settings = settings app.state.engine = engine + app.state.session_factory = make_session_factory(engine) if engine is not None else None + + def get_session(): + if app.state.session_factory is None: + raise RuntimeError("no database configured") + with app.state.session_factory() as session: + yield session + + def get_tenant() -> str: + # Single-tenant deployment today; every query is still scoped, so + # consolidation is a data copy rather than a rewrite (contract § 1.3). + return settings.tenant @app.get("/healthz") def healthz() -> dict[str, str]: @@ -59,4 +72,5 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) - "migration": readiness.migration, } + app.include_router(make_router(get_session, get_tenant)) return app diff --git a/service/src/canned_prompts_service/models.py b/service/src/canned_prompts_service/models.py index 2de75ba..b0cf643 100644 --- a/service/src/canned_prompts_service/models.py +++ b/service/src/canned_prompts_service/models.py @@ -21,6 +21,7 @@ import datetime as dt from sqlalchemy import ( BigInteger, DateTime, + Integer, ForeignKey, Index, LargeBinary, @@ -37,6 +38,12 @@ def utcnow() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) +# SQLite autoincrements INTEGER PRIMARY KEY only, never BIGINT. The variant +# keeps BIGINT on PostgreSQL, where this actually runs, while letting the +# SQLite-backed tests exercise the same models. +BigIntPK = BigInteger().with_variant(Integer, "sqlite") + + class PackageVersion(Base): """One immutable `:@` within one tenant.""" @@ -48,7 +55,7 @@ class PackageVersion(Base): Index("ix_package_versions_tenant_id", "tenant", "package_id"), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) tenant: Mapped[str] = mapped_column(String(64), nullable=False) registry: Mapped[str] = mapped_column(String(128), nullable=False) package_id: Mapped[str] = mapped_column(String(512), nullable=False) @@ -82,7 +89,7 @@ class PackageFile(Base): Index("ix_package_files_tenant", "tenant"), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) tenant: Mapped[str] = mapped_column(String(64), nullable=False) package_version_id: Mapped[int] = mapped_column( ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False @@ -107,7 +114,7 @@ class IndexEntry(Base): ), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) tenant: Mapped[str] = mapped_column(String(64), nullable=False) registry: Mapped[str] = mapped_column(String(128), nullable=False) package_id: Mapped[str] = mapped_column(String(512), nullable=False) diff --git a/service/src/canned_prompts_service/routes.py b/service/src/canned_prompts_service/routes.py new file mode 100644 index 0000000..d119cc5 --- /dev/null +++ b/service/src/canned_prompts_service/routes.py @@ -0,0 +1,136 @@ +"""Read API. + +Routes address packages with the format's own reference syntax +(`:@`, § 3.2 and § 17) rather than inventing a second +one. A package id contains `/`, so the reference is captured as a greedy path +and parsed here; each route keeps a distinct prefix so the greediness cannot +swallow a neighbouring segment. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from . import store +from .store import Ambiguous, NotFound, Reference, StoreError + +router = APIRouter() + + +def as_http(exc: StoreError) -> HTTPException: + if isinstance(exc, NotFound): + return HTTPException(status_code=404, detail=str(exc)) + if isinstance(exc, Ambiguous): + # 409, not 300: the request is answerable, but only once the caller says + # which registry they meant. + return HTTPException(status_code=409, detail=str(exc)) + return HTTPException(status_code=400, detail=str(exc)) + + +def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]: + payload = { + "registry": row.registry, + "id": row.package_id, + "version": row.version, + "reference": f"{row.registry}:{row.package_id}@{row.version}", + "name": row.name, + "summary": row.summary, + "type": row.package_type, + "license": row.license, + "tags": row.tags.split() if row.tags else [], + "content_digest": row.content_digest, + "published_at": row.published_at.isoformat() if row.published_at else None, + } + if manifest: + payload["manifest"] = row.manifest + return payload + + +def make_router(get_session, get_tenant) -> APIRouter: + api = APIRouter() + + @api.get("/packages") + def list_packages( + q: str = Query("", description="Substring over id, name, summary and tags"), + registry: str | None = Query(None), + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + rows = store.search(session, tenant, q, registry) + return {"count": len(rows), "packages": [version_payload(r) for r in rows]} + + @api.get("/packages/{reference:path}") + def get_package( + reference: str, + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + """Without `@version`, the versions of an id; with it, one manifest.""" + parsed = Reference.parse(reference) + try: + if parsed.version is None: + rows = store.versions_of(session, tenant, parsed) + return { + "id": parsed.package_id, + "registry": rows[0].registry, + "versions": [version_payload(r) for r in rows], + } + row = store.get_version(session, tenant, parsed) + return version_payload(row, manifest=True) + except StoreError as exc: + raise as_http(exc) from exc + + @api.get("/archives/{reference:path}") + def get_archive( + reference: str, + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + """The package's files. Text where it decodes, base64 otherwise.""" + try: + row = store.get_version(session, tenant, Reference.parse(reference)) + except StoreError as exc: + raise as_http(exc) from exc + + files: dict[str, Any] = {} + for item in sorted(row.files, key=lambda f: f.path): + try: + files[item.path] = {"text": item.content.decode("utf-8")} + except UnicodeDecodeError: + files[item.path] = {"base64": base64.b64encode(item.content).decode()} + return { + "reference": f"{row.registry}:{row.package_id}@{row.version}", + "content_digest": row.content_digest, + "files": files, + } + + @api.get("/index") + def get_index( + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + rows = store.index_entries(session, tenant) + return { + "count": len(rows), + "entries": [ + { + "registry": r.registry, + "id": r.package_id, + "version": r.version, + "source": r.source, + "method": r.method, + "declared_author": r.declared_author, + "declared_source": r.declared_source, + "license": r.license, + "included_at": r.included_at.isoformat() if r.included_at else None, + "last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None, + } + for r in rows + ], + } + + return api diff --git a/service/src/canned_prompts_service/store.py b/service/src/canned_prompts_service/store.py new file mode 100644 index 0000000..19bf42b --- /dev/null +++ b/service/src/canned_prompts_service/store.py @@ -0,0 +1,250 @@ +"""Package storage and queries. + +Validation is delegated to the reference implementation rather than +reimplemented. One validator means the service and the CLI cannot disagree +about what a valid package is — and a service that silently accepted something +the CLI rejects would be the divergence this whole project exists to prevent. +Importing it is not the same as changing it; `reference/` stays the +dependency-light conformance witness. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +from dataclasses import dataclass +from pathlib import Path + +import canned_prompts as cp +import yaml +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .models import IndexEntry, PackageFile, PackageVersion, utcnow + + +class StoreError(Exception): + """A request the store refuses, with an operator-readable reason.""" + + +class NotFound(StoreError): + pass + + +class Ambiguous(StoreError): + """A bare id matching more than one registry (§ 3.2): report, never guess.""" + + def __init__(self, package_id: str, registries: list[str]) -> None: + self.package_id = package_id + self.registries = registries + super().__init__( + f"{package_id} is present in more than one registry: " + + ", ".join(f"{r}:{package_id}" for r in registries) + + " — qualify the reference" + ) + + +class Conflict(StoreError): + pass + + +@dataclass(frozen=True) +class Reference: + """A `:@` reference (§ 3.2, § 17).""" + + registry: str | None + package_id: str + version: str | None + + @classmethod + def parse(cls, raw: str) -> "Reference": + rest, _, version = raw.partition("@") + registry, package_id = cp.parse_reference(rest) + return cls(registry, package_id, version or None) + + +def digest_files(files: dict[str, bytes]) -> str: + """Content digest over the package's files, order-independent.""" + hasher = hashlib.sha256() + for path in sorted(files): + hasher.update(path.encode("utf-8")) + hasher.update(b"\0") + hasher.update(files[path]) + hasher.update(b"\0") + return f"sha256:{hasher.hexdigest()}" + + +def read_package_files(package_dir: Path, manifest: dict) -> dict[str, bytes]: + """Reserved paths and manifest-referenced files only (§ 2).""" + members = cp.package_members(package_dir, manifest) + return {rel: (package_dir / rel).read_bytes() for rel in sorted(members)} + + +def ingest( + session: Session, + tenant: str, + registry: str, + package_dir: Path, + source: str, + method: str, +) -> PackageVersion: + """Validate a package and store it. Shared by publish and by fixtures.""" + manifest = cp.validate_package(package_dir) + files = read_package_files(package_dir, manifest) + digest = digest_files(files) + + existing = session.scalar( + select(PackageVersion).where( + PackageVersion.tenant == tenant, + PackageVersion.registry == registry, + PackageVersion.package_id == manifest["id"], + PackageVersion.version == manifest["version"], + ) + ) + if existing is not None: + # § 17: a published id@version is immutable within a registry. Identical + # content is a harmless re-publish; different content is a conflict. + if existing.content_digest == digest: + record_index_entry(session, tenant, registry, manifest, source, method) + return existing + raise Conflict( + f"{registry}:{manifest['id']}@{manifest['version']} already exists " + "with different content; publish a new version (§ 17)" + ) + + version = PackageVersion( + tenant=tenant, + registry=registry, + package_id=manifest["id"], + version=manifest["version"], + name=manifest.get("name", ""), + summary=str(manifest.get("summary", "")).strip(), + package_type=manifest.get("type", "template"), + license=manifest.get("license"), + tags=" ".join(str(t) for t in (manifest.get("tags") or [])), + manifest=yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True), + content_digest=digest, + files=[ + PackageFile(tenant=tenant, path=path, content=content) + for path, content in files.items() + ], + ) + session.add(version) + record_index_entry(session, tenant, registry, manifest, source, method) + session.flush() + return version + + +def record_index_entry( + session: Session, tenant: str, registry: str, manifest: dict, source: str, method: str +) -> IndexEntry: + """§ 20.3. `included_at` is first arrival and is never overwritten.""" + provenance = manifest.get("provenance") or {} + entry = session.scalar( + select(IndexEntry).where( + IndexEntry.tenant == tenant, + IndexEntry.registry == registry, + IndexEntry.package_id == manifest["id"], + IndexEntry.version == manifest["version"], + ) + ) + if entry is not None: + entry.last_seen_at = utcnow() + return entry + + entry = IndexEntry( + tenant=tenant, + registry=registry, + package_id=manifest["id"], + version=manifest["version"], + source=source, + method=method, + declared_author=str(provenance.get("author")) if provenance.get("author") else None, + declared_source=str(provenance.get("source")) if provenance.get("source") else None, + license=manifest.get("license"), + ) + session.add(entry) + return entry + + +def search( + session: Session, tenant: str, query: str = "", registry: str | None = None +) -> list[PackageVersion]: + statement = select(PackageVersion).where(PackageVersion.tenant == tenant) + if registry: + statement = statement.where(PackageVersion.registry == registry) + rows = list(session.scalars(statement)) + if query: + needle = query.lower() + rows = [ + row + for row in rows + if needle + in " ".join([row.package_id, row.name, row.summary, row.tags]).lower() + ] + return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version))) + + +def resolve_registry( + session: Session, tenant: str, reference: Reference +) -> str: + """Never guess which registry a bare id means (§ 3.2).""" + if reference.registry is not None: + return reference.registry + registries = sorted( + set( + session.scalars( + select(PackageVersion.registry).where( + PackageVersion.tenant == tenant, + PackageVersion.package_id == reference.package_id, + ) + ) + ) + ) + if not registries: + raise NotFound(f"package not found: {reference.package_id}") + if len(registries) > 1: + raise Ambiguous(reference.package_id, registries) + return registries[0] + + +def versions_of(session: Session, tenant: str, reference: Reference) -> list[PackageVersion]: + registry = resolve_registry(session, tenant, reference) + rows = list( + session.scalars( + select(PackageVersion).where( + PackageVersion.tenant == tenant, + PackageVersion.registry == registry, + PackageVersion.package_id == reference.package_id, + ) + ) + ) + if not rows: + raise NotFound(f"package not found: {reference.package_id}") + return sorted(rows, key=lambda r: cp.parse_semver(r.version), reverse=True) + + +def get_version(session: Session, tenant: str, reference: Reference) -> PackageVersion: + rows = versions_of(session, tenant, reference) + if reference.version: + for row in rows: + if row.version == reference.version: + return row + raise NotFound(f"package not found: {reference.package_id}@{reference.version}") + + # No version asked for: the § 17.1 selector rules apply, so a prerelease is + # never chosen implicitly. + chosen = cp.select_version([row.version for row in rows], None) + if chosen is None: + raise NotFound( + f"{reference.package_id}: only prerelease versions are available " + f"({', '.join(row.version for row in rows)}); name one exactly" + ) + return next(row for row in rows if row.version == chosen) + + +def index_entries(session: Session, tenant: str) -> list[IndexEntry]: + rows = list( + session.scalars(select(IndexEntry).where(IndexEntry.tenant == tenant)) + ) + return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version))) diff --git a/service/tests/conftest.py b/service/tests/conftest.py new file mode 100644 index 0000000..a76ab84 --- /dev/null +++ b/service/tests/conftest.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from canned_prompts_service.api import create_app +from canned_prompts_service.db import make_engine, make_session_factory +from canned_prompts_service.settings import Settings +from canned_prompts_service import store + +ROOT = Path(__file__).resolve().parents[1] +REPO = ROOT.parent + + +@pytest.fixture() +def db_url(tmp_path: Path) -> str: + url = f"sqlite:///{tmp_path / 'svc.db'}" + config = Config(str(ROOT / "alembic.ini")) + config.set_main_option("script_location", str(ROOT / "migrations")) + config.set_main_option("sqlalchemy.url", url) + command.upgrade(config, "head") + return url + + +@pytest.fixture() +def session(db_url: str) -> Session: + with make_session_factory(make_engine(db_url))() as s: + yield s + + +@pytest.fixture() +def client(db_url: str) -> TestClient: + return TestClient(create_app(Settings(database_url=db_url), make_engine(db_url))) + + +@pytest.fixture() +def seeded(db_url: str) -> TestClient: + """A store holding the repo's own example packages.""" + engine = make_engine(db_url) + with make_session_factory(engine)() as s: + for name in ("house-style", "pqrst-estimate"): + store.ingest(s, "default", "local", REPO / "examples" / name, + source=f"examples/{name}", method="publish") + s.commit() + return TestClient(create_app(Settings(database_url=db_url), engine)) + + +@pytest.fixture() +def two_registries(db_url: str, tmp_path: Path) -> TestClient: + """The same id in two registries — § 3.2 ambiguity.""" + engine = make_engine(db_url) + with make_session_factory(engine)() as s: + for registry in ("local", "house"): + store.ingest(s, "default", registry, REPO / "examples" / "house-style", + source=registry, method="publish") + s.commit() + return TestClient(create_app(Settings(database_url=db_url), engine)) diff --git a/service/tests/test_read_api.py b/service/tests/test_read_api.py new file mode 100644 index 0000000..cd01aab --- /dev/null +++ b/service/tests/test_read_api.py @@ -0,0 +1,87 @@ +"""Read API (CANP-WP-0006 T03).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_search_returns_stored_packages(seeded: TestClient) -> None: + body = seeded.get("/packages").json() + assert body["count"] == 2 + assert {p["id"] for p in body["packages"]} == { + "practice/house-style", + "practice/pqrst-estimate", + } + + +def test_search_matches_summary_and_tags(seeded: TestClient) -> None: + assert seeded.get("/packages", params={"q": "pqrst"}).json()["count"] == 1 + assert seeded.get("/packages", params={"q": "fragment"}).json()["count"] == 1 + assert seeded.get("/packages", params={"q": "nothing-matches"}).json()["count"] == 0 + + +def test_versions_of_a_bare_id(seeded: TestClient) -> None: + body = seeded.get("/packages/practice/pqrst-estimate").json() + assert body["id"] == "practice/pqrst-estimate" + assert [v["version"] for v in body["versions"]] == ["1.0.0"] + + +def test_manifest_by_qualified_reference(seeded: TestClient) -> None: + """The API speaks the format's own :@ syntax.""" + body = seeded.get("/packages/local:practice/pqrst-estimate@1.0.0").json() + assert body["reference"] == "local:practice/pqrst-estimate@1.0.0" + assert "PQRST" in body["manifest"] + assert body["content_digest"].startswith("sha256:") + + +def test_unknown_package_is_404(seeded: TestClient) -> None: + assert seeded.get("/packages/nope/missing").status_code == 404 + + +def test_unknown_version_is_404(seeded: TestClient) -> None: + assert seeded.get("/packages/practice/house-style@9.9.9").status_code == 404 + + +def test_ambiguous_bare_id_is_409_not_a_guess(two_registries: TestClient) -> None: + """§ 3.2: report the candidates, never choose for the caller.""" + response = two_registries.get("/packages/practice/house-style") + assert response.status_code == 409 + detail = response.json()["detail"] + assert "more than one registry" in detail + assert "house:practice/house-style" in detail + assert "local:practice/house-style" in detail + + +def test_qualifying_resolves_the_ambiguity(two_registries: TestClient) -> None: + body = two_registries.get("/packages/house:practice/house-style").json() + assert body["registry"] == "house" + + +def test_archive_returns_the_packaged_files(seeded: TestClient) -> None: + body = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json() + assert set(body["files"]) == { + "LICENSE", + "README.md", + "evals/canonical-fidelity.yaml", + "examples/basic.yaml", + "examples/with-rationale.yaml", + "prompt.md", + "prompt.yaml", + } + assert "PQRST-Estimate" in body["files"]["prompt.md"]["text"] + + +def test_archive_excludes_non_package_files(seeded: TestClient) -> None: + """§ 2: only reserved paths and manifest-referenced files are stored.""" + files = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json()["files"] + assert not any(path.startswith(".") for path in files) + + +def test_index_records_arrival(seeded: TestClient) -> None: + body = seeded.get("/index").json() + assert body["count"] == 2 + entry = next(e for e in body["entries"] if e["id"] == "practice/pqrst-estimate") + assert entry["method"] == "publish" + assert entry["source"] == "examples/pqrst-estimate" + assert entry["declared_source"] == "~/pqrst-practice/PqrstPrompt.md" + assert entry["included_at"] is not None diff --git a/workplans/CANP-WP-0006-hosted-registry-service.md b/workplans/CANP-WP-0006-hosted-registry-service.md index 79a9871..6e8ca10 100644 --- a/workplans/CANP-WP-0006-hosted-registry-service.md +++ b/workplans/CANP-WP-0006-hosted-registry-service.md @@ -114,7 +114,7 @@ consolidation time. Uniqueness is `(tenant, registry, package_id, version)`. ```task id: CANP-WP-0006-T03 -status: todo +status: done priority: high state_hub_task_id: "0ae11e65-68b0-543c-b8fa-f3188a4d239a" ``` @@ -127,6 +127,30 @@ state_hub_task_id: "0ae11e65-68b0-543c-b8fa-f3188a4d239a" Ambiguity is reported, never guessed (§ 3.2): a bare id matching packages from more than one registry returns the candidates, not a choice. +**Done**, with the route shape worth recording. Package ids contain `/`, so the +obvious `/packages/{id}/{version}` is ambiguous under a greedy path parameter. +Rather than invent an HTTP-specific identifier, the routes speak the format's +own `:@` syntax and parse it — `:` and `@` are both legal +in a path segment, each route keeps a distinct prefix, and the API therefore +tests § 3.2's reference notation rather than working around it. + +Ambiguity returns **409 with the candidates**, not 300: the request is +answerable once the caller says which registry they meant. Omitting a version +applies § 17.1's selectors, so a prerelease is never chosen implicitly. + +Validation is delegated to `reference/`, installed into the service +environment rather than reimplemented. One validator means the service and the +CLI cannot disagree about what a valid package is — a service accepting +something the CLI rejects would be exactly the divergence this project exists to +prevent. Importing it is not changing it; `reference/` stays the +dependency-light conformance witness. + +**Test-vs-production difference found and handled.** SQLite autoincrements +`INTEGER PRIMARY KEY` only, never `BIGINT`, so the SQLite-backed tests could not +insert a row. `BigInteger().with_variant(Integer, "sqlite")` keeps `BIGINT` on +PostgreSQL, where this actually runs, while letting the tests exercise the same +models and the same migration. + ## Publish API ```task