Section 17 already defined what a registry does when the same id@version is republished; nothing defined what a *consumer* does. That is where namespace conflict actually bites — in the catalog, after installing from two registries. Identity is now registry-scoped: an id names a package within a registry, the way a path names a file within a repository. A local-first format with no signing, no federation and no central authority cannot enforce global uniqueness, and an unenforceable guarantee is worse than none — it invites consumers to conflate two packages that merely share a name. A qualified `<registry>:<id>` reference distinguishes them, and `:` is now barred from ids so the separator stays available. Installing the same id from two registries is therefore not a conflict. The catalog is namespaced by registry and keeps both. Ownership is registry policy, not package data. An optional `registry.yaml` names a registry and records namespace claims. Those claims are explicitly descriptive — a filesystem registry cannot authenticate a publisher, and `publish` says so rather than implying it checked. Keeping the claim out of packages leaves artifacts free of unverifiable assertions of authority, and means package semantics do not change when a hosted registry appears later. Spec: 3.2 (registry-scoped identity, qualified references), 17 (immutability scoped to a registry), 20.1 and 20.2 (new), 18 (registry-manifest validation), 21 (qualified references, reserved `local` name). Reference CLI: parse_reference, check_registry_name, read_registry_manifest, registry_name, namespace_policy; registry_package_path and catalog_package_path split; resolve_installed reports ambiguity and returns the source registry; iter_catalog; `add --as`; closed-namespace warning on publish. Tests 11 -> 21. The catalog layout changed. An existing catalog is detected and reported with instructions rather than failing as "package not found". Signing, trust scoring and federation remain non-goals and were not touched. 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
338 lines
9.4 KiB
Python
338 lines
9.4 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import canned_prompts as cp
|
|
|
|
|
|
@pytest.fixture()
|
|
def package(tmp_path: Path) -> Path:
|
|
pkg = tmp_path / "pkg"
|
|
pkg.mkdir()
|
|
(pkg / "prompt.yaml").write_text(
|
|
"""\
|
|
format: canned-prompt/v0.1
|
|
id: demo/hello
|
|
name: Hello
|
|
version: 1.0.0
|
|
summary: Say hello.
|
|
template: prompt.md
|
|
inputs:
|
|
- name: person
|
|
required: true
|
|
parameters:
|
|
tone:
|
|
type: enum
|
|
values: [warm, formal]
|
|
default: warm
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
(pkg / "prompt.md").write_text(
|
|
"Say hello to {{ person }} in a {{ tone }} tone.\n", encoding="utf-8"
|
|
)
|
|
return pkg
|
|
|
|
|
|
def test_validate_and_render(package: Path) -> None:
|
|
manifest = cp.validate_package(package)
|
|
values = cp.resolve_values(manifest, {"person": "Ada"})
|
|
rendered = cp.render_template((package / "prompt.md").read_text(), values)
|
|
assert rendered == "Say hello to Ada in a warm tone.\n"
|
|
|
|
|
|
def test_missing_required_input_fails(package: Path) -> None:
|
|
manifest = cp.validate_package(package)
|
|
with pytest.raises(cp.CannedPromptError, match="missing required input"):
|
|
cp.resolve_values(manifest, {})
|
|
|
|
|
|
def test_undeclared_placeholder_fails(package: Path) -> None:
|
|
(package / "prompt.md").write_text("{{ missing }}\n", encoding="utf-8")
|
|
with pytest.raises(cp.CannedPromptError, match="undeclared placeholders"):
|
|
cp.validate_package(package)
|
|
|
|
|
|
def write_pkg(pkg: Path, manifest: str, template: str = "{{ greeting }}\n") -> Path:
|
|
pkg.mkdir(exist_ok=True)
|
|
(pkg / "prompt.yaml").write_text(manifest, encoding="utf-8")
|
|
(pkg / "prompt.md").write_text(template, encoding="utf-8")
|
|
return pkg
|
|
|
|
|
|
BASE = """\
|
|
format: canned-prompt/v0.1
|
|
id: demo/defaults
|
|
name: Defaults
|
|
version: 1.0.0
|
|
summary: Exercise input defaults.
|
|
template: prompt.md
|
|
"""
|
|
|
|
|
|
def test_static_default_fills_optional_input(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default: "hello there"
|
|
""",
|
|
)
|
|
manifest = cp.validate_package(pkg)
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
assert resolution.values["greeting"] == "hello there"
|
|
assert resolution.origins["greeting"] == "default"
|
|
assert cp.render_template("{{ greeting }}", resolution.values) == "hello there"
|
|
|
|
|
|
def test_supplied_value_overrides_static_default(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default: "hello there"
|
|
""",
|
|
)
|
|
manifest = cp.validate_package(pkg)
|
|
resolution = cp.resolve_inputs(manifest, {"greeting": "hi"})
|
|
assert resolution.values["greeting"] == "hi"
|
|
assert resolution.origins["greeting"] == "supplied"
|
|
|
|
|
|
def test_derived_default_uses_static_fallback(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
dependencies:
|
|
prompts:
|
|
- id: context/greeting
|
|
version: 1.0.0
|
|
requirement: generate
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default:
|
|
derive: context/greeting
|
|
value: "(none)"
|
|
""",
|
|
)
|
|
manifest = cp.validate_package(pkg)
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
assert resolution.values["greeting"] == "(none)"
|
|
assert resolution.origins["greeting"] == "fallback (not derived)"
|
|
assert resolution.underivable == []
|
|
|
|
|
|
def test_derived_default_without_fallback_is_underivable(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
dependencies:
|
|
prompts:
|
|
- id: context/greeting
|
|
version: 1.0.0
|
|
requirement: generate
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default:
|
|
derive: context/greeting
|
|
""",
|
|
)
|
|
manifest = cp.validate_package(pkg)
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
assert resolution.underivable == ["greeting"]
|
|
assert "greeting" not in resolution.values
|
|
with pytest.raises(cp.CannedPromptError, match="unresolved placeholder"):
|
|
cp.render_template("{{ greeting }}", resolution.values)
|
|
|
|
|
|
def test_inline_derive_is_valid(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default:
|
|
derive:
|
|
prompt: Produce a greeting suited to the audience.
|
|
value: "(none)"
|
|
""",
|
|
)
|
|
manifest = cp.validate_package(pkg)
|
|
assert cp.resolve_inputs(manifest, {}).values["greeting"] == "(none)"
|
|
|
|
|
|
def test_default_with_required_true_fails(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: true
|
|
default: "hello"
|
|
""",
|
|
)
|
|
with pytest.raises(cp.CannedPromptError, match="required: true"):
|
|
cp.validate_package(pkg)
|
|
|
|
|
|
def test_derive_reference_must_be_declared(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default:
|
|
derive: context/greeting
|
|
""",
|
|
)
|
|
with pytest.raises(cp.CannedPromptError, match="not declared in dependencies"):
|
|
cp.validate_package(pkg)
|
|
|
|
|
|
def test_inline_derive_cannot_also_reference(tmp_path: Path) -> None:
|
|
pkg = write_pkg(
|
|
tmp_path / "p",
|
|
BASE
|
|
+ """\
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default:
|
|
derive:
|
|
prompt: Produce a greeting.
|
|
id: context/greeting
|
|
""",
|
|
)
|
|
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
|
cp.validate_package(pkg)
|
|
|
|
|
|
MINIMAL = """\
|
|
format: canned-prompt/v0.1
|
|
id: practice/thing
|
|
name: Thing
|
|
version: 1.0.0
|
|
summary: A thing.
|
|
template: prompt.md
|
|
inputs:
|
|
- name: greeting
|
|
required: false
|
|
default: hi
|
|
"""
|
|
|
|
|
|
def test_parse_reference() -> None:
|
|
assert cp.parse_reference("practice/thing") == (None, "practice/thing")
|
|
assert cp.parse_reference("house:practice/thing") == ("house", "practice/thing")
|
|
with pytest.raises(cp.CannedPromptError, match="malformed reference"):
|
|
cp.parse_reference("house:")
|
|
|
|
|
|
def test_registry_name_falls_back_to_basename(tmp_path: Path) -> None:
|
|
registry = tmp_path / "upstream"
|
|
registry.mkdir()
|
|
assert cp.registry_name(registry) == "upstream"
|
|
|
|
|
|
def test_registry_name_from_manifest(tmp_path: Path) -> None:
|
|
registry = tmp_path / "some-dir"
|
|
registry.mkdir()
|
|
(registry / "registry.yaml").write_text(
|
|
"format: canned-prompt-registry/v0.1\nname: house\n", encoding="utf-8"
|
|
)
|
|
assert cp.registry_name(registry) == "house"
|
|
|
|
|
|
def test_registry_manifest_rejects_bad_format(tmp_path: Path) -> None:
|
|
registry = tmp_path / "r"
|
|
registry.mkdir()
|
|
(registry / "registry.yaml").write_text(
|
|
"format: something-else\nname: house\n", encoding="utf-8"
|
|
)
|
|
with pytest.raises(cp.CannedPromptError, match="unsupported registry format"):
|
|
cp.read_registry_manifest(registry)
|
|
|
|
|
|
def test_registry_manifest_rejects_bad_policy(tmp_path: Path) -> None:
|
|
registry = tmp_path / "r"
|
|
registry.mkdir()
|
|
(registry / "registry.yaml").write_text(
|
|
"format: canned-prompt-registry/v0.1\n"
|
|
"name: house\n"
|
|
"namespaces:\n practice:\n policy: maybe\n",
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(cp.CannedPromptError, match="policy must be"):
|
|
cp.read_registry_manifest(registry)
|
|
|
|
|
|
def test_namespace_policy(tmp_path: Path) -> None:
|
|
registry = tmp_path / "r"
|
|
registry.mkdir()
|
|
(registry / "registry.yaml").write_text(
|
|
"format: canned-prompt-registry/v0.1\n"
|
|
"name: house\n"
|
|
"namespaces:\n practice:\n owner: Ada\n policy: closed\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert cp.namespace_policy(registry, "practice/thing")[0] == "closed"
|
|
assert cp.namespace_policy(registry, "scratch/thing")[0] == "open"
|
|
|
|
|
|
def install_into(catalog: Path, registry: str) -> None:
|
|
"""Place a package in the catalog under a given registry name."""
|
|
dst = cp.catalog_package_path(catalog, registry, "practice/thing", "1.0.0")
|
|
dst.mkdir(parents=True)
|
|
(dst / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
|
(dst / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
|
|
|
|
|
def test_same_id_from_two_registries_coexists(tmp_path: Path) -> None:
|
|
catalog = tmp_path / "catalog"
|
|
install_into(catalog, "house")
|
|
install_into(catalog, "upstream")
|
|
assert cp.catalog_registries(catalog) == ["house", "upstream"]
|
|
|
|
package_dir, registry = cp.resolve_installed(catalog, "house:practice/thing", None)
|
|
assert registry == "house"
|
|
assert package_dir.is_dir()
|
|
|
|
|
|
def test_bare_id_in_two_registries_is_ambiguous(tmp_path: Path) -> None:
|
|
catalog = tmp_path / "catalog"
|
|
install_into(catalog, "house")
|
|
install_into(catalog, "upstream")
|
|
with pytest.raises(cp.CannedPromptError, match="more than one registry"):
|
|
cp.resolve_installed(catalog, "practice/thing", None)
|
|
|
|
|
|
def test_bare_id_in_one_registry_resolves(tmp_path: Path) -> None:
|
|
catalog = tmp_path / "catalog"
|
|
install_into(catalog, "house")
|
|
_, registry = cp.resolve_installed(catalog, "practice/thing", None)
|
|
assert registry == "house"
|
|
|
|
|
|
def test_legacy_catalog_layout_is_reported(tmp_path: Path) -> None:
|
|
catalog = tmp_path / "catalog"
|
|
legacy = catalog / "practice" / "thing" / "1.0.0"
|
|
legacy.mkdir(parents=True)
|
|
(legacy / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
|
(legacy / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
|
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
|
|
cp.resolve_installed(catalog, "practice/thing", None)
|