initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
"""Core platform contracts and runtime registry."""

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import Iterable
from importlib.metadata import EntryPoint, entry_points
from govoplan_core.core.modules import ModuleManifest
ENTRY_POINT_GROUP = "govoplan.modules"
class ModuleDiscoveryError(RuntimeError):
pass
def _load_manifest(entry_point: EntryPoint) -> ModuleManifest:
loaded = entry_point.load()
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest):
raise ModuleDiscoveryError(f"Entry point {entry_point.name!r} did not return a ModuleManifest")
return manifest
def iter_module_entry_points(group: str = ENTRY_POINT_GROUP) -> tuple[EntryPoint, ...]:
discovered = entry_points()
if hasattr(discovered, "select"):
return tuple(discovered.select(group=group))
return tuple(discovered.get(group, ()))
def discover_module_manifests(
*,
enabled_modules: Iterable[str] | None = None,
group: str = ENTRY_POINT_GROUP,
) -> tuple[ModuleManifest, ...]:
enabled = set(enabled_modules) if enabled_modules is not None else None
manifests: list[ModuleManifest] = []
for entry_point in iter_module_entry_points(group):
manifest = _load_manifest(entry_point)
if enabled is not None and manifest.id not in enabled:
continue
manifests.append(manifest)
return tuple(sorted(manifests, key=lambda item: item.id))

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@dataclass(frozen=True, slots=True)
class PlatformEvent:
type: str
module_id: str
payload: Mapping[str, Any] = field(default_factory=dict)
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
EventHandler = Callable[[PlatformEvent], None]
class EventBus:
def __init__(self) -> None:
self._subscribers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_type: str, handler: EventHandler) -> None:
self._subscribers[event_type].append(handler)
def publish(self, event: PlatformEvent) -> None:
for handler in self._subscribers.get(event.type, ()):
handler(event)
for handler in self._subscribers.get("*", ()):
handler(event)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import MetaData
from govoplan_core.core.registry import PlatformRegistry
@dataclass(frozen=True, slots=True)
class MigrationMetadataPlan:
metadata: tuple[MetaData, ...]
script_locations: tuple[str, ...]
def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan:
metadata = list(extra_metadata)
script_locations: list[str] = []
for manifest in registry.manifests():
spec = manifest.migration_spec
if spec is None:
continue
if isinstance(spec.metadata, MetaData):
metadata.append(spec.metadata)
if spec.script_location:
script_locations.append(spec.script_location)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations))

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import APIRouter
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@dataclass(frozen=True, slots=True)
class PermissionDefinition:
scope: str
label: str
description: str
category: str
level: PermissionLevel
module_id: str
resource: str
action: str
deprecated: bool = False
@dataclass(frozen=True, slots=True)
class RoleTemplate:
slug: str
name: str
description: str
permissions: tuple[str, ...]
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
@dataclass(frozen=True, slots=True)
class NavItem:
path: str
label: str
icon: str | None = None
section: str | None = None
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
@dataclass(frozen=True, slots=True)
class FrontendRoute:
path: str
component: str
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
@dataclass(frozen=True, slots=True)
class FrontendModule:
module_id: str
package_name: str | None = None
asset_manifest: str | None = None
routes: tuple[FrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@dataclass(frozen=True, slots=True)
class MigrationSpec:
module_id: str
metadata: object | None = None
script_location: str | None = None
@dataclass(frozen=True, slots=True)
class AccessDecision:
allowed: bool
reason: str | None = None
requirements: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleContext:
registry: object
settings: object
data: Mapping[str, Any] = field(default_factory=dict)
class ResourceAclProvider(Protocol):
resource_type: str
def can_read(self, principal: object, resource_id: str) -> bool:
...
def can_write(self, principal: object, resource_id: str) -> bool:
...
def explain(self, principal: object, resource_id: str) -> AccessDecision:
...
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
RouteFactory = Callable[[ModuleContext], "APIRouter"]
@dataclass(frozen=True, slots=True)
class ModuleManifest:
id: str
name: str
version: str
dependencies: tuple[str, ...] = ()
optional_dependencies: tuple[str, ...] = ()
permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None
migration_spec: MigrationSpec | None = None
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import re
from collections import defaultdict, deque
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from govoplan_core.core.modules import (
DeleteVetoProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ResourceAclProvider,
RoleTemplate,
TenantSummaryProvider,
)
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
class RegistryError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class RegistrySnapshot:
manifests: tuple[ModuleManifest, ...]
permissions: tuple[PermissionDefinition, ...]
role_templates: tuple[RoleTemplate, ...]
nav_items: tuple[NavItem, ...]
class PlatformRegistry:
def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list)
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
raise RegistryError(f"Duplicate module id: {manifest.id}")
self._manifests[manifest.id] = manifest
for provider in manifest.tenant_summary_providers:
self.register_tenant_summary_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(resource_type, provider)
return manifest
def get(self, module_id: str) -> ModuleManifest | None:
return self._manifests.get(module_id)
def has_module(self, module_id: str) -> bool:
return module_id in self._manifests
def require_module(self, module_id: str) -> ModuleManifest:
manifest = self.get(module_id)
if manifest is None:
raise RegistryError(f"Required module is not installed: {module_id}")
return manifest
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
manifest = self.require_module(module_id)
return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id))
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
def role_templates(self) -> tuple[RoleTemplate, ...]:
return tuple(template for manifest in self.manifests() for template in manifest.role_templates)
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers)
def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(provider)
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
return tuple(self._delete_veto_providers.get(resource_type, ()))
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered:
for dependency in manifest.dependencies:
if dependency not in self._manifests:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
return RegistrySnapshot(
manifests=ordered,
permissions=tuple(seen_permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(),
)
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()}
dependents: dict[str, set[str]] = defaultdict(set)
for module_id, dependencies in incoming.items():
for dependency in dependencies:
dependents[dependency].add(module_id)
ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies))
ordered: list[str] = []
while ready:
module_id = ready.popleft()
ordered.append(module_id)
for dependent in sorted(dependents.get(module_id, ())):
incoming[dependent].discard(module_id)
if not incoming[dependent]:
ready.append(dependent)
if len(ordered) != len(self._manifests):
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
return (self._manifests[module_id] for module_id in ordered)