feat(docs): define semantic subject contract
This commit is contained in:
@@ -61,6 +61,11 @@ from govoplan_core.core.search import (
|
||||
SearchProvider,
|
||||
SearchSourceProvider,
|
||||
)
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX,
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
semantic_documentation_subject_capability,
|
||||
)
|
||||
from govoplan_core.core.tasks import (
|
||||
RegisteredWorkItemProvider,
|
||||
WorkItemProvider,
|
||||
@@ -1215,6 +1220,48 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||
"documentation contract version must not be empty"
|
||||
)
|
||||
|
||||
semantic_capabilities = tuple(
|
||||
capability
|
||||
for capability in manifest.capability_factories
|
||||
if capability.startswith(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
|
||||
)
|
||||
)
|
||||
for capability in semantic_capabilities:
|
||||
expected = semantic_documentation_subject_capability(manifest.id)
|
||||
if capability != expected:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
f"must be {expected!r}, not {capability!r}"
|
||||
)
|
||||
metadata = manifest.capability_documentation.get(capability)
|
||||
if metadata is None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
"must declare capability documentation"
|
||||
)
|
||||
if (
|
||||
metadata.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
f"must declare contract version "
|
||||
f"{SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION!r}"
|
||||
)
|
||||
static_types = {
|
||||
documentation_type
|
||||
for topic in manifest.documentation
|
||||
for documentation_type in topic.documentation_types
|
||||
}
|
||||
missing_types = {"admin", "user"} - static_types
|
||||
if missing_types:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation provider must "
|
||||
"retain static user and administrator documentation baselines; "
|
||||
f"missing: {', '.join(sorted(missing_types))}"
|
||||
)
|
||||
|
||||
provider_keys: set[str] = set()
|
||||
for registration in manifest.documentation_configuration_providers:
|
||||
if not registration.keys:
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX = (
|
||||
"documentation.semantic_subjects."
|
||||
)
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION = "1"
|
||||
|
||||
SemanticDocumentationSubjectAvailability = Literal[
|
||||
"available",
|
||||
"changed",
|
||||
"superseded",
|
||||
"missing",
|
||||
"temporarily_unavailable",
|
||||
]
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,119}$")
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,254}$")
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
|
||||
_REASON_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
class SemanticDocumentationContractError(ValueError):
|
||||
"""Raised when a semantic-documentation subject violates the Core contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectAnchor:
|
||||
kind: str
|
||||
id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.kind, _KIND_RE, "Semantic subject anchor kind")
|
||||
_require_match(self.id, _IDENTIFIER_RE, "Semantic subject anchor id")
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"kind": self.kind, "id": self.id}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, object]
|
||||
) -> SemanticDocumentationSubjectAnchor:
|
||||
_require_keys(value, {"kind", "id"}, "Semantic subject anchor")
|
||||
return cls(kind=_required_text(value, "kind"), id=_required_text(value, "id"))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectReference:
|
||||
module_id: str
|
||||
tenant_id: str
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None
|
||||
observed_revision: str | None = None
|
||||
observed_fingerprint: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.module_id, _MODULE_ID_RE, "Semantic subject module id")
|
||||
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic subject tenant id")
|
||||
_require_match(self.subject_kind, _KIND_RE, "Semantic subject kind")
|
||||
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic subject id")
|
||||
_optional_text(self.observed_revision, "Semantic subject observed revision", 255)
|
||||
if self.observed_fingerprint is not None and not _SHA256_RE.fullmatch(
|
||||
self.observed_fingerprint
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject observed fingerprint must be a SHA-256 digest."
|
||||
)
|
||||
|
||||
@property
|
||||
def stable_key(self) -> str:
|
||||
identity = {
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
"module_id": self.module_id,
|
||||
"subject_id": self.subject_id,
|
||||
"subject_kind": self.subject_kind,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"subject_kind": self.subject_kind,
|
||||
"subject_id": self.subject_id,
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
"observed_revision": self.observed_revision,
|
||||
"observed_fingerprint": self.observed_fingerprint,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, object]
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
_require_keys(
|
||||
value,
|
||||
{
|
||||
"module_id",
|
||||
"tenant_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"anchor",
|
||||
"observed_revision",
|
||||
"observed_fingerprint",
|
||||
},
|
||||
"Semantic subject reference",
|
||||
)
|
||||
raw_anchor = value.get("anchor")
|
||||
if raw_anchor is not None and not isinstance(raw_anchor, Mapping):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject anchor must be an object."
|
||||
)
|
||||
return cls(
|
||||
module_id=_required_text(value, "module_id"),
|
||||
tenant_id=_required_text(value, "tenant_id"),
|
||||
subject_kind=_required_text(value, "subject_kind"),
|
||||
subject_id=_required_text(value, "subject_id"),
|
||||
anchor=(
|
||||
SemanticDocumentationSubjectAnchor.from_mapping(raw_anchor)
|
||||
if isinstance(raw_anchor, Mapping)
|
||||
else None
|
||||
),
|
||||
observed_revision=_mapping_optional_text(value, "observed_revision"),
|
||||
observed_fingerprint=_mapping_optional_text(
|
||||
value, "observed_fingerprint"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationBreadcrumb:
|
||||
label: str
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required_bounded_text(self.label, "Semantic subject breadcrumb label", 300)
|
||||
_require_match(self.subject_kind, _KIND_RE, "Semantic breadcrumb kind")
|
||||
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic breadcrumb id")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"subject_kind": self.subject_kind,
|
||||
"subject_id": self.subject_id,
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectDescriptor:
|
||||
reference: SemanticDocumentationSubjectReference
|
||||
labels: Mapping[str, str]
|
||||
descriptions: Mapping[str, str] = field(default_factory=dict)
|
||||
breadcrumbs: tuple[SemanticDocumentationBreadcrumb, ...] = ()
|
||||
route: str | None = None
|
||||
route_anchor: str | None = None
|
||||
audience: tuple[str, ...] = ()
|
||||
classification: str = "internal"
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.reference.observed_revision:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject descriptors require a current revision."
|
||||
)
|
||||
if not self.reference.observed_fingerprint:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject descriptors require a current fingerprint."
|
||||
)
|
||||
_localized_text(self.labels, "Semantic subject labels", required=True, limit=300)
|
||||
_localized_text(
|
||||
self.descriptions,
|
||||
"Semantic subject descriptions",
|
||||
required=False,
|
||||
limit=2_000,
|
||||
)
|
||||
if len(self.breadcrumbs) > 32:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject breadcrumbs are limited to 32 items."
|
||||
)
|
||||
if self.route is not None:
|
||||
_optional_text(self.route, "Semantic subject route", 2_000)
|
||||
if not self.route.startswith("/") or self.route.startswith("//"):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject routes must be local absolute paths."
|
||||
)
|
||||
if self.route_anchor is not None:
|
||||
_require_match(
|
||||
self.route_anchor,
|
||||
_IDENTIFIER_RE,
|
||||
"Semantic subject route anchor",
|
||||
)
|
||||
_text_tuple(self.audience, "Semantic subject audience", maximum=32)
|
||||
_required_bounded_text(
|
||||
self.classification, "Semantic subject classification", 120
|
||||
)
|
||||
_text_tuple(
|
||||
self.required_scopes, "Semantic subject required scopes", maximum=64
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"reference": self.reference.to_dict(),
|
||||
"labels": dict(self.labels),
|
||||
"descriptions": dict(self.descriptions),
|
||||
"breadcrumbs": [item.to_dict() for item in self.breadcrumbs],
|
||||
"route": self.route,
|
||||
"route_anchor": self.route_anchor,
|
||||
"audience": list(self.audience),
|
||||
"classification": self.classification,
|
||||
"required_scopes": list(self.required_scopes),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectResolution:
|
||||
requested_reference: SemanticDocumentationSubjectReference
|
||||
availability: SemanticDocumentationSubjectAvailability
|
||||
subject: SemanticDocumentationSubjectDescriptor | None = None
|
||||
superseded_by: SemanticDocumentationSubjectReference | None = None
|
||||
reason_code: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reason_code is not None:
|
||||
_require_match(
|
||||
self.reason_code, _REASON_CODE_RE, "Semantic resolution reason code"
|
||||
)
|
||||
if self.availability in {"available", "changed"}:
|
||||
if self.subject is None:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions require a descriptor."
|
||||
)
|
||||
if (
|
||||
self.subject.reference.stable_key
|
||||
!= self.requested_reference.stable_key
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject resolution changed the requested identity."
|
||||
)
|
||||
changed = _reference_changed(
|
||||
self.requested_reference, self.subject.reference
|
||||
)
|
||||
if self.availability == "available" and changed:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Changed semantic subjects must use the changed availability."
|
||||
)
|
||||
if self.availability == "changed" and not changed:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Changed semantic subject resolutions require a revision or fingerprint change."
|
||||
)
|
||||
elif self.subject is not None:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions cannot include a descriptor."
|
||||
)
|
||||
if self.availability == "superseded":
|
||||
if self.superseded_by is None:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Superseded semantic subjects require a replacement reference."
|
||||
)
|
||||
elif self.superseded_by is not None:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Only superseded semantic subjects may declare a replacement."
|
||||
)
|
||||
if self.availability in {"missing", "temporarily_unavailable"} and not self.reason_code:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions require a reason code."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"requested_reference": self.requested_reference.to_dict(),
|
||||
"availability": self.availability,
|
||||
"subject": self.subject.to_dict() if self.subject else None,
|
||||
"superseded_by": (
|
||||
self.superseded_by.to_dict() if self.superseded_by else None
|
||||
),
|
||||
"reason_code": self.reason_code,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectQuery:
|
||||
tenant_id: str
|
||||
query: str = ""
|
||||
subject_kinds: tuple[str, ...] = ()
|
||||
limit: int = 50
|
||||
cursor: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic query tenant id")
|
||||
if not isinstance(self.query, str) or len(self.query) > 300:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject query must be text of at most 300 characters."
|
||||
)
|
||||
if self.query:
|
||||
_required_bounded_text(self.query, "Semantic subject query", 300)
|
||||
if not 1 <= self.limit <= 200:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject query limit must be between 1 and 200."
|
||||
)
|
||||
_text_tuple(self.subject_kinds, "Semantic query subject kinds", maximum=100)
|
||||
for kind in self.subject_kinds:
|
||||
_require_match(kind, _KIND_RE, "Semantic query subject kind")
|
||||
_optional_text(self.cursor, "Semantic query cursor", 1_000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectPage:
|
||||
subjects: tuple[SemanticDocumentationSubjectDescriptor, ...] = ()
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
keys = tuple(item.reference.stable_key for item in self.subjects)
|
||||
if len(keys) != len(set(keys)):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject pages cannot contain duplicate identities."
|
||||
)
|
||||
_optional_text(self.next_cursor, "Semantic subject page cursor", 1_000)
|
||||
if self.has_more and not self.next_cursor:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject pages with more results require a cursor."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SemanticDocumentationSubjectProvider(Protocol):
|
||||
provider_id: str
|
||||
module_id: str
|
||||
contract_version: str
|
||||
|
||||
def list_subjects(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: SemanticDocumentationSubjectQuery,
|
||||
) -> SemanticDocumentationSubjectPage: ...
|
||||
|
||||
def resolve_subject(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
"""Return None when the principal may not know whether a subject exists."""
|
||||
|
||||
|
||||
def semantic_documentation_subject_capability(module_id: str) -> str:
|
||||
_require_match(module_id, _MODULE_ID_RE, "Semantic subject module id")
|
||||
return f"{SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX}{module_id}"
|
||||
|
||||
|
||||
def semantic_documentation_subject_provider_names(
|
||||
registry: object | None,
|
||||
) -> tuple[str, ...]:
|
||||
if registry is None or not hasattr(registry, "capability_names"):
|
||||
return ()
|
||||
return tuple(
|
||||
str(name)
|
||||
for name in registry.capability_names()
|
||||
if str(name).startswith(SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def semantic_documentation_subject_providers(
|
||||
registry: object | None,
|
||||
) -> tuple[tuple[str, SemanticDocumentationSubjectProvider], ...]:
|
||||
if registry is None or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
providers: list[tuple[str, SemanticDocumentationSubjectProvider]] = []
|
||||
for capability_name in semantic_documentation_subject_provider_names(registry):
|
||||
module_id = capability_name.removeprefix(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
|
||||
)
|
||||
provider = registry.capability(capability_name)
|
||||
if not isinstance(provider, SemanticDocumentationSubjectProvider):
|
||||
raise TypeError(
|
||||
f"Invalid semantic-documentation provider capability: {capability_name}"
|
||||
)
|
||||
if provider.module_id != module_id:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider module {provider.module_id!r} does not match "
|
||||
f"capability {capability_name!r}."
|
||||
)
|
||||
if (
|
||||
provider.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Unsupported semantic-documentation provider contract: "
|
||||
f"{provider.contract_version!r}."
|
||||
)
|
||||
providers.append((module_id, provider))
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def list_semantic_documentation_subjects(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: SemanticDocumentationSubjectQuery,
|
||||
) -> tuple[tuple[str, SemanticDocumentationSubjectPage], ...]:
|
||||
if _principal_tenant_id(principal) != request.tenant_id:
|
||||
return ()
|
||||
pages: list[tuple[str, SemanticDocumentationSubjectPage]] = []
|
||||
for module_id, provider in semantic_documentation_subject_providers(registry):
|
||||
page = provider.list_subjects(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
if any(
|
||||
subject.reference.module_id != module_id
|
||||
or subject.reference.tenant_id != request.tenant_id
|
||||
for subject in page.subjects
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider {module_id!r} returned a foreign subject."
|
||||
)
|
||||
pages.append((module_id, page))
|
||||
return tuple(pages)
|
||||
|
||||
|
||||
def resolve_semantic_documentation_subject(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
if _principal_tenant_id(principal) != reference.tenant_id:
|
||||
return None
|
||||
capability_name = semantic_documentation_subject_capability(reference.module_id)
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability="temporarily_unavailable",
|
||||
reason_code="provider_unavailable",
|
||||
)
|
||||
provider = registry.capability(capability_name)
|
||||
if not isinstance(provider, SemanticDocumentationSubjectProvider):
|
||||
raise TypeError(
|
||||
f"Invalid semantic-documentation provider capability: {capability_name}"
|
||||
)
|
||||
if (
|
||||
provider.module_id != reference.module_id
|
||||
or provider.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider {capability_name!r} does not match the Core contract."
|
||||
)
|
||||
result = provider.resolve_subject(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
if result.requested_reference != reference:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic provider returned a resolution for another reference."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def semantic_documentation_fingerprint(value: object) -> str:
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic fingerprint input must be canonical JSON data."
|
||||
) from exc
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
|
||||
def _reference_changed(
|
||||
requested: SemanticDocumentationSubjectReference,
|
||||
current: SemanticDocumentationSubjectReference,
|
||||
) -> bool:
|
||||
comparisons = (
|
||||
(requested.observed_revision, current.observed_revision),
|
||||
(requested.observed_fingerprint, current.observed_fingerprint),
|
||||
)
|
||||
return any(expected is not None and expected != actual for expected, actual in comparisons)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
return str(getattr(principal, "tenant_id", "") or "")
|
||||
|
||||
|
||||
def _localized_text(
|
||||
values: Mapping[str, str],
|
||||
label: str,
|
||||
*,
|
||||
required: bool,
|
||||
limit: int,
|
||||
) -> None:
|
||||
if required and not values:
|
||||
raise SemanticDocumentationContractError(f"{label} are required.")
|
||||
if len(values) > 20:
|
||||
raise SemanticDocumentationContractError(f"{label} are limited to 20 locales.")
|
||||
for locale, value in values.items():
|
||||
if not _LOCALE_RE.fullmatch(str(locale)):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} contain an invalid locale: {locale!r}."
|
||||
)
|
||||
_required_bounded_text(value, f"{label} value", limit)
|
||||
|
||||
|
||||
def _text_tuple(values: Sequence[str], label: str, *, maximum: int) -> None:
|
||||
if len(values) > maximum:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} are limited to {maximum} items."
|
||||
)
|
||||
normalized = tuple(str(value).strip() for value in values)
|
||||
if any(not value or len(value) > 255 for value in normalized):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} must contain non-empty bounded text."
|
||||
)
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise SemanticDocumentationContractError(f"{label} must be unique.")
|
||||
|
||||
|
||||
def _require_match(value: str, pattern: re.Pattern[str], label: str) -> None:
|
||||
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||
raise SemanticDocumentationContractError(f"{label} is invalid.")
|
||||
|
||||
|
||||
def _required_bounded_text(value: str, label: str, limit: int) -> None:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} must be non-empty and at most {limit} characters."
|
||||
)
|
||||
if any(ord(character) < 32 and character not in "\n\t" for character in value):
|
||||
raise SemanticDocumentationContractError(f"{label} contains control characters.")
|
||||
|
||||
|
||||
def _optional_text(value: str | None, label: str, limit: int) -> None:
|
||||
if value is not None:
|
||||
_required_bounded_text(value, label, limit)
|
||||
|
||||
|
||||
def _require_keys(
|
||||
value: Mapping[str, object], allowed: set[str], label: str
|
||||
) -> None:
|
||||
unexpected = sorted(str(key) for key in value if str(key) not in allowed)
|
||||
if unexpected:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} contains unsupported fields: {', '.join(unexpected)}."
|
||||
)
|
||||
|
||||
|
||||
def _required_text(value: Mapping[str, object], key: str) -> str:
|
||||
result = value.get(key)
|
||||
if not isinstance(result, str) or not result.strip():
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject field {key} is required."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _mapping_optional_text(value: Mapping[str, object], key: str) -> str | None:
|
||||
result = value.get(key)
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, str):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject field {key} must be text."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX",
|
||||
"SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION",
|
||||
"SemanticDocumentationBreadcrumb",
|
||||
"SemanticDocumentationContractError",
|
||||
"SemanticDocumentationSubjectAnchor",
|
||||
"SemanticDocumentationSubjectAvailability",
|
||||
"SemanticDocumentationSubjectDescriptor",
|
||||
"SemanticDocumentationSubjectPage",
|
||||
"SemanticDocumentationSubjectProvider",
|
||||
"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",
|
||||
]
|
||||
Reference in New Issue
Block a user