CANP-WP-0006 T03: read API

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 <registry>:<id>@<version> 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 <noreply@anthropic.com>
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
This commit is contained in:
tegwick 2026-09-06 20:23:25 +02:00
parent d5782d4d04
commit 5f47da8036
15 changed files with 641 additions and 11 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
[console_scripts]
canned-prompts = canned_prompts:main

View file

@ -0,0 +1 @@
PyYAML<7,>=6.0

View file

@ -0,0 +1 @@
canned_prompts

View file

@ -66,8 +66,32 @@ hold the same id.
| `package_files` | the package's files as content, not parsed into rows | | `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` | | `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
(`<registry>:<id>@<version>`, § 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=&registry=` | 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 ## Status
`CANP-WP-0006` T01 and T02 are done: skeleton, health surface, tenant-keyed `CANP-WP-0006` T01T03 are done: skeleton, health surface, tenant-keyed schema
schema and migration `0001`. The read API, publish API, HTTP registry client and and migration `0001`, and the read API. The publish API, HTTP registry client
container image (T03T06) are not built yet. and container image (T04T06) are not built yet. T06 produces the image digest
that `rapp-canned-prompts` needs to pin.

View file

@ -11,6 +11,10 @@ Revises:
from alembic import op from alembic import op
import sqlalchemy as sa 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' revision = '0001'
down_revision = None down_revision = None
branch_labels = None branch_labels = None
@ -20,7 +24,7 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('index_entries', 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('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False), sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), 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') sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_index_entry')
) )
op.create_table('package_versions', 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('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False), sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), 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_index('ix_package_versions_tenant_id', 'package_versions', ['tenant', 'package_id'], unique=False)
op.create_table('package_files', 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('tenant', sa.String(length=64), nullable=False),
sa.Column('package_version_id', sa.BigInteger(), nullable=False), sa.Column('package_version_id', sa.BigInteger(), nullable=False),
sa.Column('path', sa.String(length=1024), nullable=False), sa.Column('path', sa.String(length=1024), nullable=False),

View file

@ -16,7 +16,8 @@ from fastapi import FastAPI, Response
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from . import __version__ 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 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 = FastAPI(title="canned-prompts registry", version=__version__)
app.state.settings = settings app.state.settings = settings
app.state.engine = engine 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") @app.get("/healthz")
def healthz() -> dict[str, str]: def healthz() -> dict[str, str]:
@ -59,4 +72,5 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) -
"migration": readiness.migration, "migration": readiness.migration,
} }
app.include_router(make_router(get_session, get_tenant))
return app return app

View file

@ -21,6 +21,7 @@ import datetime as dt
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
DateTime, DateTime,
Integer,
ForeignKey, ForeignKey,
Index, Index,
LargeBinary, LargeBinary,
@ -37,6 +38,12 @@ def utcnow() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc) 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): class PackageVersion(Base):
"""One immutable `<registry>:<id>@<version>` within one tenant.""" """One immutable `<registry>:<id>@<version>` within one tenant."""
@ -48,7 +55,7 @@ class PackageVersion(Base):
Index("ix_package_versions_tenant_id", "tenant", "package_id"), 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) tenant: Mapped[str] = mapped_column(String(64), nullable=False)
registry: Mapped[str] = mapped_column(String(128), nullable=False) registry: Mapped[str] = mapped_column(String(128), nullable=False)
package_id: Mapped[str] = mapped_column(String(512), 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"), 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) tenant: Mapped[str] = mapped_column(String(64), nullable=False)
package_version_id: Mapped[int] = mapped_column( package_version_id: Mapped[int] = mapped_column(
ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False 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) tenant: Mapped[str] = mapped_column(String(64), nullable=False)
registry: Mapped[str] = mapped_column(String(128), nullable=False) registry: Mapped[str] = mapped_column(String(128), nullable=False)
package_id: Mapped[str] = mapped_column(String(512), nullable=False) package_id: Mapped[str] = mapped_column(String(512), nullable=False)

View file

@ -0,0 +1,136 @@
"""Read API.
Routes address packages with the format's own reference syntax
(`<registry>:<id>@<version>`, § 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

View file

@ -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 `<registry>:<id>@<version>` 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)))

63
service/tests/conftest.py Normal file
View file

@ -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))

View file

@ -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 <registry>:<id>@<version> 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

View file

@ -114,7 +114,7 @@ consolidation time. Uniqueness is `(tenant, registry, package_id, version)`.
```task ```task
id: CANP-WP-0006-T03 id: CANP-WP-0006-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "0ae11e65-68b0-543c-b8fa-f3188a4d239a" 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 Ambiguity is reported, never guessed (§ 3.2): a bare id matching packages from
more than one registry returns the candidates, not a choice. 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 `<registry>:<id>@<version>` 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 ## Publish API
```task ```task