initial commit after split
This commit is contained in:
2
src/govoplan_core/core/__init__.py
Normal file
2
src/govoplan_core/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core platform contracts and runtime registry."""
|
||||
|
||||
42
src/govoplan_core/core/discovery.py
Normal file
42
src/govoplan_core/core/discovery.py
Normal 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))
|
||||
33
src/govoplan_core/core/events.py
Normal file
33
src/govoplan_core/core/events.py
Normal 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)
|
||||
|
||||
29
src/govoplan_core/core/migrations.py
Normal file
29
src/govoplan_core/core/migrations.py
Normal 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))
|
||||
|
||||
125
src/govoplan_core/core/modules.py
Normal file
125
src/govoplan_core/core/modules.py
Normal 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)
|
||||
|
||||
155
src/govoplan_core/core/registry.py
Normal file
155
src/govoplan_core/core/registry.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user