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:
parent
d5782d4d04
commit
5f47da8036
15 changed files with 641 additions and 11 deletions
6
reference/canned_prompts_reference.egg-info/PKG-INFO
Normal file
6
reference/canned_prompts_reference.egg-info/PKG-INFO
Normal 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
|
||||
10
reference/canned_prompts_reference.egg-info/SOURCES.txt
Normal file
10
reference/canned_prompts_reference.egg-info/SOURCES.txt
Normal 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[console_scripts]
|
||||
canned-prompts = canned_prompts:main
|
||||
1
reference/canned_prompts_reference.egg-info/requires.txt
Normal file
1
reference/canned_prompts_reference.egg-info/requires.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
PyYAML<7,>=6.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
canned_prompts
|
||||
|
|
@ -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
|
||||
(`<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=®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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<registry>:<id>@<version>` 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)
|
||||
|
|
|
|||
136
service/src/canned_prompts_service/routes.py
Normal file
136
service/src/canned_prompts_service/routes.py
Normal 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
|
||||
250
service/src/canned_prompts_service/store.py
Normal file
250
service/src/canned_prompts_service/store.py
Normal 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
63
service/tests/conftest.py
Normal 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))
|
||||
87
service/tests/test_read_api.py
Normal file
87
service/tests/test_read_api.py
Normal 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
|
||||
|
|
@ -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 `<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
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue