feat(docs): define semantic subject contract
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationTopic,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
SemanticDocumentationBreadcrumb,
|
||||
SemanticDocumentationContractError,
|
||||
SemanticDocumentationSubjectAnchor,
|
||||
SemanticDocumentationSubjectDescriptor,
|
||||
SemanticDocumentationSubjectPage,
|
||||
SemanticDocumentationSubjectQuery,
|
||||
SemanticDocumentationSubjectReference,
|
||||
SemanticDocumentationSubjectResolution,
|
||||
list_semantic_documentation_subjects,
|
||||
resolve_semantic_documentation_subject,
|
||||
semantic_documentation_fingerprint,
|
||||
semantic_documentation_subject_capability,
|
||||
semantic_documentation_subject_provider_names,
|
||||
semantic_documentation_subject_providers,
|
||||
)
|
||||
|
||||
|
||||
def reference(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str | None = "4",
|
||||
fingerprint: str | None = None,
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id=tenant_id,
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
anchor=anchor,
|
||||
observed_revision=revision,
|
||||
observed_fingerprint=fingerprint
|
||||
or semantic_documentation_fingerprint({"revision": revision}),
|
||||
)
|
||||
|
||||
|
||||
def descriptor(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str = "4",
|
||||
fingerprint: str | None = None,
|
||||
label: str = "Permit application",
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectDescriptor:
|
||||
return SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(
|
||||
tenant_id=tenant_id,
|
||||
revision=revision,
|
||||
fingerprint=fingerprint,
|
||||
anchor=anchor,
|
||||
),
|
||||
labels={"de": "Antrag auf Parkerlaubnis", "en": label},
|
||||
descriptions={"de": "Konfiguriertes Antragsformular."},
|
||||
breadcrumbs=(
|
||||
SemanticDocumentationBreadcrumb(
|
||||
label="Forms",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
),
|
||||
),
|
||||
route="/forms/permit-application",
|
||||
route_anchor=anchor.id if anchor else None,
|
||||
audience=("case_worker",),
|
||||
classification="internal",
|
||||
required_scopes=("forms:definition:read",),
|
||||
)
|
||||
|
||||
|
||||
class Provider:
|
||||
provider_id = "forms.semantic_subjects"
|
||||
module_id = "forms"
|
||||
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_calls = 0
|
||||
self.resolve_calls = 0
|
||||
|
||||
def list_subjects(self, _session, principal, *, request):
|
||||
self.list_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return SemanticDocumentationSubjectPage()
|
||||
return SemanticDocumentationSubjectPage(subjects=(descriptor(),))
|
||||
|
||||
def resolve_subject(self, _session, principal, *, reference):
|
||||
self.resolve_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return None
|
||||
current = descriptor()
|
||||
availability = (
|
||||
"changed"
|
||||
if reference.observed_fingerprint
|
||||
!= current.reference.observed_fingerprint
|
||||
else "available"
|
||||
)
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability=availability,
|
||||
subject=current,
|
||||
)
|
||||
|
||||
|
||||
class SemanticDocumentationContractTests(unittest.TestCase):
|
||||
def test_reference_identity_is_stable_across_labels_and_revisions(self) -> None:
|
||||
original = descriptor(label="Old label")
|
||||
renamed = descriptor(revision="5", label="New label")
|
||||
|
||||
self.assertEqual(
|
||||
original.reference.stable_key,
|
||||
renamed.reference.stable_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
original.reference.observed_fingerprint,
|
||||
renamed.reference.observed_fingerprint,
|
||||
)
|
||||
|
||||
def test_nested_anchor_is_typed_and_changes_subject_identity(self) -> None:
|
||||
form = reference()
|
||||
field = reference(
|
||||
anchor=SemanticDocumentationSubjectAnchor(
|
||||
kind="field",
|
||||
id="vehicle-registration",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertNotEqual(form.stable_key, field.stable_key)
|
||||
restored = SemanticDocumentationSubjectReference.from_mapping(
|
||||
field.to_dict()
|
||||
)
|
||||
self.assertEqual(restored, field)
|
||||
|
||||
def test_reference_mapping_rejects_unbounded_provider_metadata(self) -> None:
|
||||
payload = reference().to_dict()
|
||||
payload["credentials"] = {"token": "must-not-cross"}
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"unsupported fields: credentials",
|
||||
):
|
||||
SemanticDocumentationSubjectReference.from_mapping(payload)
|
||||
|
||||
def test_descriptors_reject_remote_routes_and_missing_localization(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"local absolute paths",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={"de": "Formular"},
|
||||
route="https://provider.example/form",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"labels are required",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={},
|
||||
)
|
||||
|
||||
def test_resolution_distinguishes_change_supersession_and_absence(self) -> None:
|
||||
old = reference(
|
||||
revision="3",
|
||||
fingerprint=semantic_documentation_fingerprint({"revision": "3"}),
|
||||
)
|
||||
current = descriptor()
|
||||
changed = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
superseded = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="superseded",
|
||||
superseded_by=SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id="tenant-1",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application-v2",
|
||||
),
|
||||
)
|
||||
missing = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="missing",
|
||||
reason_code="subject_removed",
|
||||
)
|
||||
|
||||
self.assertEqual(changed.availability, "changed")
|
||||
self.assertEqual(superseded.superseded_by.subject_id, "permit-application-v2")
|
||||
self.assertEqual(missing.reason_code, "subject_removed")
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"require a revision or fingerprint change",
|
||||
):
|
||||
SemanticDocumentationSubjectResolution(
|
||||
requested_reference=current.reference,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
|
||||
def test_subject_pages_reject_duplicate_stable_identities(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"duplicate identities",
|
||||
):
|
||||
SemanticDocumentationSubjectPage(
|
||||
subjects=(descriptor(), descriptor(label="Renamed")),
|
||||
)
|
||||
|
||||
def test_registry_discovers_optional_providers_without_direct_imports(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_provider_names(registry),
|
||||
("documentation.semantic_subjects.forms",),
|
||||
)
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_providers(registry),
|
||||
(("forms", provider),),
|
||||
)
|
||||
|
||||
def test_listing_and_resolution_are_tenant_and_principal_aware(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
allowed = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
wrong_tenant = SimpleNamespace(
|
||||
tenant_id="tenant-2", account_id="account-1"
|
||||
)
|
||||
denied = SimpleNamespace(tenant_id="tenant-1", account_id="denied")
|
||||
|
||||
pages = list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
allowed,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
)
|
||||
self.assertEqual(len(pages), 1)
|
||||
self.assertEqual(pages[0][1].subjects[0].reference.tenant_id, "tenant-1")
|
||||
self.assertEqual(
|
||||
list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
wrong_tenant,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
object(),
|
||||
denied,
|
||||
reference=reference(),
|
||||
)
|
||||
)
|
||||
self.assertEqual(provider.list_calls, 1)
|
||||
self.assertEqual(provider.resolve_calls, 1)
|
||||
|
||||
def test_missing_optional_module_resolves_without_loading_feature_code(self) -> None:
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
result = resolve_semantic_documentation_subject(
|
||||
PlatformRegistry(),
|
||||
object(),
|
||||
principal,
|
||||
reference=reference(),
|
||||
)
|
||||
|
||||
self.assertEqual(result.availability, "temporarily_unavailable")
|
||||
self.assertEqual(result.reason_code, "provider_unavailable")
|
||||
|
||||
def test_manifest_requires_exact_capability_docs_and_static_baselines(self) -> None:
|
||||
provider = Provider()
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
missing_baseline = PlatformRegistry()
|
||||
missing_baseline.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantics",
|
||||
summary="Lists documentation-safe configured form subjects.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"),),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "static user and administrator"):
|
||||
missing_baseline.validate()
|
||||
|
||||
wrong_capability = PlatformRegistry()
|
||||
wrong_capability.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={
|
||||
semantic_documentation_subject_capability("workflow"): (
|
||||
lambda _context: provider
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "must be"):
|
||||
wrong_capability.validate()
|
||||
|
||||
def test_fingerprint_is_canonical_and_rejects_non_json_values(self) -> None:
|
||||
self.assertEqual(
|
||||
semantic_documentation_fingerprint({"b": 2, "a": 1}),
|
||||
semantic_documentation_fingerprint({"a": 1, "b": 2}),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"canonical JSON",
|
||||
):
|
||||
semantic_documentation_fingerprint({"unsafe": object()})
|
||||
|
||||
|
||||
def static_topic(documentation_type: str) -> DocumentationTopic:
|
||||
return DocumentationTopic(
|
||||
id=f"forms.semantic.{documentation_type}",
|
||||
title="Form semantics",
|
||||
summary="Static form semantics baseline.",
|
||||
documentation_types=(documentation_type,), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def provider_registry(provider: Provider) -> PlatformRegistry:
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantic subjects",
|
||||
summary="Lists safe configured forms and fields for Docs.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"), static_topic("user")),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user