feat: version search source contracts

This commit is contained in:
2026-07-29 18:08:52 +02:00
parent 13893c80cd
commit 920e3c9834
4 changed files with 456 additions and 2 deletions

View File

@@ -8,7 +8,10 @@ from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING: if TYPE_CHECKING:
from fastapi import APIRouter from fastapi import APIRouter
from govoplan_core.core.search import SearchProviderRegistration from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -358,6 +361,7 @@ class ModuleManifest:
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = () uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict) capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = () search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility) compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None on_deactivate: LifecycleHook | None = None

View File

@@ -25,7 +25,12 @@ from govoplan_core.core.modules import (
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
) )
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import RegisteredSearchProvider, SearchProvider from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.views import ( from govoplan_core.core.views import (
ViewSurface, ViewSurface,
module_view_surface_id, module_view_surface_id,
@@ -63,6 +68,10 @@ class PlatformRegistry:
self._capability_context: ModuleContext | None = None self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = [] self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {} self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest: def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests: if manifest.id in self._manifests:
@@ -82,6 +91,13 @@ class PlatformRegistry:
registration=registration, registration=registration,
) )
) )
for registration in manifest.search_sources:
self._search_source_registrations.append(
RegisteredSearchSourceProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot: def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -102,8 +118,12 @@ class PlatformRegistry:
self._search_provider_registrations = list( self._search_provider_registrations = list(
replacement._search_provider_registrations replacement._search_provider_registrations
) )
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._capabilities.clear() self._capabilities.clear()
self._search_providers.clear() self._search_providers.clear()
self._search_sources.clear()
return snapshot return snapshot
def get(self, module_id: str) -> ModuleManifest | None: def get(self, module_id: str) -> ModuleManifest | None:
@@ -148,6 +168,7 @@ class PlatformRegistry:
self._capability_context = context self._capability_context = context
self._capabilities.clear() self._capabilities.clear()
self._search_providers.clear() self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None: def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories: if name in self._capability_factories:
@@ -206,6 +227,46 @@ class PlatformRegistry:
providers.append((registered, provider)) providers.append((registered, provider))
return tuple(providers) return tuple(providers)
def search_source_registrations(
self,
) -> tuple[RegisteredSearchSourceProvider, ...]:
return tuple(
sorted(
self._search_source_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_sources(
self,
) -> tuple[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
...,
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None: def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider self._tenant_summary_providers[module_id] = provider
@@ -521,6 +582,19 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
f"{registration.id!r}" f"{registration.id!r}"
) )
provider_ids.add(registration.id) provider_ids.add(registration.id)
source_ids: set[str] = set()
for registration in manifest.search_sources:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search source id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search source "
f"{registration.id!r}"
)
source_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None: def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import ExternalObjectReference from govoplan_core.core.external_references import ExternalObjectReference
@@ -10,6 +11,8 @@ from govoplan_core.core.modules import ModuleContext
SearchContextKind = Literal["global", "module", "resource"] SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"] SearchVisibility = Literal["tenant", "restricted"]
SearchIndexChangeKind = Literal["upsert", "delete"]
SEARCH_SOURCE_CONTRACT_VERSION = "1"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -22,6 +25,8 @@ class SearchQuery:
context_id: str | None = None context_id: str | None = None
limit: int = 25 limit: int = 25
offset: int = 0 offset: int = 0
cursor: str | None = None
language: str = "simple"
def __post_init__(self) -> None: def __post_init__(self) -> None:
normalized_text = self.text.strip() normalized_text = self.text.strip()
@@ -33,6 +38,16 @@ class SearchQuery:
raise ValueError("Search result limits must be between 1 and 200.") raise ValueError("Search result limits must be between 1 and 200.")
if self.offset < 0: if self.offset < 0:
raise ValueError("Search offsets cannot be negative.") raise ValueError("Search offsets cannot be negative.")
if self.cursor and self.offset:
raise ValueError(
"Search cursor and offset pagination cannot be combined."
)
if not self.language.replace("_", "").isalnum():
raise ValueError("Search language configuration is invalid.")
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
raise ValueError(
"Search queries support at most 50 module and type filters."
)
object.__setattr__(self, "text", normalized_text) object.__setattr__(self, "text", normalized_text)
@@ -50,6 +65,68 @@ class SearchResult:
breadcrumbs: tuple[str, ...] = () breadcrumbs: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
source_revision: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SearchResourceReference:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
for field_name in (
"tenant_id",
"module_id",
"resource_type",
"resource_id",
):
if not str(getattr(self, field_name) or "").strip():
raise ValueError(
f"Search resource reference {field_name} is required."
)
@property
def key(self) -> str:
values = (
self.tenant_id,
self.module_id,
self.resource_type,
self.resource_id,
)
return "|".join(
f"{len(value)}:{value}"
for value in values
)
@dataclass(frozen=True, slots=True)
class SearchResourceType:
provider_id: str
module_id: str
resource_type: str
label: str
index_version: int = 1
language: str = "simple"
requires_authorization_recheck: bool = True
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.provider_id,
self.module_id,
self.resource_type,
self.label,
)
):
raise ValueError(
"Search resource type identifiers and label are required."
)
if self.index_version < 1:
raise ValueError("Search index versions start at one.")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -67,6 +144,13 @@ class SearchDocument:
acl_tokens: tuple[str, ...] = () acl_tokens: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
provider_id: str | None = None
source_revision: str = "1"
change_cursor: str | None = None
source_updated_at: datetime | None = None
language: str = "simple"
index_version: int = 1
requires_authorization_recheck: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
required = { required = {
@@ -80,10 +164,152 @@ class SearchDocument:
for field_name, value in required.items(): for field_name, value in required.items():
if not str(value or "").strip(): if not str(value or "").strip():
raise ValueError(f"Search document {field_name} is required.") raise ValueError(f"Search document {field_name} is required.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"title": 500,
"url": 1500,
"summary": 4_000,
"body": 200_000,
"source_revision": 255,
"change_cursor": 500,
"language": 32,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(str(value)) > limit:
raise ValueError(
f"Search document {field_name} is limited to "
f"{limit} characters."
)
if len(self.keywords) > 100 or any(
len(keyword) > 200
for keyword in self.keywords
):
raise ValueError(
"Search documents support at most 100 bounded keywords."
)
if len(self.acl_tokens) > 500 or any(
len(token) > 500
for token in self.acl_tokens
):
raise ValueError(
"Search documents support at most 500 bounded ACL tokens."
)
if self.visibility == "restricted" and not self.acl_tokens: if self.visibility == "restricted" and not self.acl_tokens:
raise ValueError( raise ValueError(
"Restricted search documents require at least one ACL token." "Restricted search documents require at least one ACL token."
) )
if not self.source_revision.strip():
raise ValueError(
"Search documents require a stable source revision."
)
if self.index_version < 1:
raise ValueError("Search document index versions start at one.")
if (
self.requires_authorization_recheck
and not str(self.provider_id or "").strip()
):
raise ValueError(
"Search documents requiring authorization rechecks must "
"name their source provider."
)
if self.provider_id is not None and len(self.provider_id) > 200:
raise ValueError(
"Search document provider_id is limited to 200 characters."
)
@property
def reference(self) -> SearchResourceReference:
return SearchResourceReference(
tenant_id=self.tenant_id,
module_id=self.module_id,
resource_type=self.resource_type,
resource_id=self.resource_id,
)
@dataclass(frozen=True, slots=True)
class SearchAuthorizationRequest:
reference: SearchResourceReference
source_revision: str
@dataclass(frozen=True, slots=True)
class SearchBackfillRequest:
tenant_id: str
provider_id: str
resource_type: str
rebuild_id: str
cursor: str | None = None
limit: int = 100
def __post_init__(self) -> None:
if not 1 <= self.limit <= 500:
raise ValueError(
"Search backfill page size must be between 1 and 500."
)
@dataclass(frozen=True, slots=True)
class SearchBackfillPage:
documents: tuple[SearchDocument, ...]
next_cursor: str | None
complete: bool
high_watermark: str | None = None
def __post_init__(self) -> None:
if len(self.documents) > 500:
raise ValueError(
"Search backfill providers returned more than 500 documents."
)
if not self.complete and not self.next_cursor:
raise ValueError(
"Incomplete search backfill pages require a next cursor."
)
@dataclass(frozen=True, slots=True)
class SearchIndexChange:
change_id: str
provider_id: str
kind: SearchIndexChangeKind
reference: SearchResourceReference
source_revision: str
cursor: str
document: SearchDocument | None = None
occurred_at: datetime | None = None
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.change_id,
self.provider_id,
self.source_revision,
self.cursor,
)
):
raise ValueError(
"Search index changes require stable identity and revision."
)
if self.kind == "upsert":
if (
self.document is None
or self.document.reference != self.reference
or self.document.provider_id != self.provider_id
or self.document.source_revision
!= self.source_revision
or self.document.change_cursor != self.cursor
):
raise ValueError(
"Search upserts require a matching document."
)
elif self.document is not None:
raise ValueError(
"Search deletes must not contain a document."
)
@runtime_checkable @runtime_checkable
@@ -121,8 +347,43 @@ class SearchIndexWriter(Protocol):
) -> bool: ) -> bool:
... ...
def enqueue_change(
self,
session: object,
*,
change: SearchIndexChange,
) -> bool:
...
@runtime_checkable
class SearchSourceProvider(Protocol):
def resource_types(self) -> Sequence[SearchResourceType]:
...
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
...
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
"""Return an explicit decision for every requested reference key."""
SearchProviderFactory = Callable[[ModuleContext], SearchProvider] SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
[ModuleContext],
SearchSourceProvider,
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -147,6 +408,28 @@ class RegisteredSearchProvider:
registration: SearchProviderRegistration registration: SearchProviderRegistration
@dataclass(frozen=True, slots=True)
class SearchSourceProviderRegistration:
id: str
factory: SearchSourceProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> SearchSourceProvider:
provider = self.factory(context)
if not isinstance(provider, SearchSourceProvider):
raise TypeError(
f"Search source {self.id!r} does not implement "
"SearchSourceProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchSourceProvider:
module_id: str
registration: SearchSourceProviderRegistration
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer" CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
@@ -164,15 +447,27 @@ def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
__all__ = [ __all__ = [
"CAPABILITY_SEARCH_INDEX_WRITER", "CAPABILITY_SEARCH_INDEX_WRITER",
"SEARCH_SOURCE_CONTRACT_VERSION",
"RegisteredSearchProvider", "RegisteredSearchProvider",
"RegisteredSearchSourceProvider",
"SearchAuthorizationRequest",
"SearchBackfillPage",
"SearchBackfillRequest",
"SearchContextKind", "SearchContextKind",
"SearchDocument", "SearchDocument",
"SearchIndexChange",
"SearchIndexChangeKind",
"SearchIndexWriter", "SearchIndexWriter",
"SearchProvider", "SearchProvider",
"SearchProviderFactory", "SearchProviderFactory",
"SearchProviderRegistration", "SearchProviderRegistration",
"SearchQuery", "SearchQuery",
"SearchResourceReference",
"SearchResourceType",
"SearchResult", "SearchResult",
"SearchSourceProvider",
"SearchSourceProviderFactory",
"SearchSourceProviderRegistration",
"SearchVisibility", "SearchVisibility",
"search_index_writer", "search_index_writer",
] ]

View File

@@ -5,11 +5,18 @@ import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.search import ( from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument, SearchDocument,
SearchProvider, SearchProvider,
SearchProviderRegistration, SearchProviderRegistration,
SearchQuery, SearchQuery,
SearchResourceReference,
SearchResourceType,
SearchResult, SearchResult,
SearchSourceProvider,
SearchSourceProviderRegistration,
) )
@@ -28,6 +35,34 @@ class _Provider:
) )
class _Source:
def resource_types(self):
return (
SearchResourceType(
provider_id="test.records",
module_id="test",
resource_type="record",
label="Records",
),
)
def backfill(self, session, *, request):
del session
return SearchBackfillPage(
documents=(),
next_cursor=None,
complete=True,
high_watermark=request.cursor,
)
def authorize(self, session, principal, *, requests):
del session, principal
return {
request.reference.key: True
for request in requests
}
class SearchContractTests(unittest.TestCase): class SearchContractTests(unittest.TestCase):
def test_query_and_restricted_document_are_bounded(self) -> None: def test_query_and_restricted_document_are_bounded(self) -> None:
query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20) query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20)
@@ -52,6 +87,17 @@ class SearchContractTests(unittest.TestCase):
title="Permit", title="Permit",
url="/cases/case-1", url="/cases/case-1",
) )
with self.assertRaisesRegex(ValueError, "body"):
SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
body="x" * 200_001,
acl_tokens=("account:account-1",),
)
def test_manifest_provider_registration_resolves_lazily(self) -> None: def test_manifest_provider_registration_resolves_lazily(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
@@ -67,6 +113,12 @@ class SearchContractTests(unittest.TestCase):
resource_types=("record",), resource_types=("record",),
), ),
), ),
search_sources=(
SearchSourceProviderRegistration(
id="test.records",
factory=lambda context: _Source(),
),
),
) )
) )
registry.configure_capability_context( registry.configure_capability_context(
@@ -76,6 +128,35 @@ class SearchContractTests(unittest.TestCase):
providers = registry.search_providers() providers = registry.search_providers()
self.assertEqual(1, len(providers)) self.assertEqual(1, len(providers))
self.assertIsInstance(providers[0][1], SearchProvider) self.assertIsInstance(providers[0][1], SearchProvider)
sources = registry.search_sources()
self.assertEqual(1, len(sources))
self.assertIsInstance(sources[0][1], SearchSourceProvider)
request = SearchAuthorizationRequest(
reference=SearchResourceReference(
tenant_id="tenant-1",
module_id="test",
resource_type="record",
resource_id="1",
),
source_revision="1",
)
self.assertTrue(
sources[0][1].authorize(
object(),
object(),
requests=(request,),
)[request.reference.key]
)
page = sources[0][1].backfill(
object(),
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id="test.records",
resource_type="record",
rebuild_id="rebuild-1",
),
)
self.assertTrue(page.complete)
if __name__ == "__main__": if __name__ == "__main__":