feat: add datasource and definition graph contracts

This commit is contained in:
2026-07-28 12:42:49 +02:00
parent d36bb94335
commit 8b1910b5b7
13 changed files with 1297 additions and 4 deletions

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import unittest
from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS,
DatasourceCatalogueProvider,
DatasourceDescriptor,
DatasourceField,
DatasourceLifecycleProvider,
DatasourceMaterialization,
DatasourceOrigin,
DatasourceOriginProvider,
DatasourceOriginReadResult,
DatasourceReadResult,
DatasourceStage,
datasource_catalogue,
datasource_lifecycle,
datasource_origins,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
descriptor = DatasourceDescriptor(
ref="datasource:source-1",
source_name="monthly_cases",
name="Monthly cases",
kind="upload",
mode="static",
shape="tabular",
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
fingerprint="abc123",
)
def list_datasources(self, session, principal, *, query="", limit=100):
del session, principal, query
return (self.descriptor,)[:limit]
def get_datasource(self, session, principal, *, datasource_ref):
del session, principal
return self.descriptor if datasource_ref == self.descriptor.ref else None
def read_datasource(self, session, principal, *, request):
del session, principal, request
return DatasourceReadResult(
datasource=self.descriptor,
rows=({"case_id": "0012"},),
total_rows=1,
truncated=False,
)
def list_materializations(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return ()
def list_stages(self, session, principal, *, limit=100):
del session, principal, limit
return ()
def create_stage(self, session, principal, *, stage):
del session, principal, stage
return DatasourceStage(
ref="stage:1",
name="Stage",
source_name="stage",
kind="upload",
mode="static",
shape="tabular",
state="ready",
)
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
del session, principal, stage_ref, freeze, frozen_label
return self.descriptor, DatasourceMaterialization(
ref="materialization:1",
datasource_ref=self.descriptor.ref,
revision=1,
state="published",
fingerprint=self.descriptor.fingerprint,
)
def register_origin(self, session, principal, **kwargs):
del session, principal, kwargs
return self.descriptor
def refresh_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.promote_stage(object(), object(), stage_ref="stage:1")
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
del session, principal, datasource_ref, label
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
def retire_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.descriptor
def list_origins(self, session, principal, *, query="", limit=100):
del session, principal, query
return (
DatasourceOrigin(
ref="origin:1",
source_name="origin",
name="Origin",
kind="database",
shape="tabular",
supported_modes=("live", "cached"),
provider="test",
),
)[:limit]
def get_origin(self, session, principal, *, origin_ref):
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
def read_origin(self, session, principal, *, request):
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
return DatasourceOriginReadResult(
origin=origin,
rows=({"id": 1},),
total_rows=1,
truncated=False,
)
class DatasourceContractTests(unittest.TestCase):
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, DatasourceCatalogueProvider)
self.assertIsInstance(provider, DatasourceLifecycleProvider)
self.assertIsInstance(provider, DatasourceOriginProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="datasource_contract_test",
name="Datasource contract test",
version="test",
capability_factories={
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
},
)
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(provider, datasource_catalogue(registry))
self.assertIs(provider, datasource_lifecycle(registry))
self.assertIs(provider, datasource_origins(registry))
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
descriptor = _Provider.descriptor
self.assertEqual("static", descriptor.mode)
self.assertEqual("upload", descriptor.kind)
self.assertEqual("tabular", descriptor.shape)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import unittest
from govoplan_core.core.definition_graphs import (
DefinitionEdge,
DefinitionGraphConstraints,
DefinitionGraphLibrary,
DefinitionNode,
DefinitionNodeCountConstraint,
DefinitionNodeType,
DefinitionPort,
validate_definition_graph,
)
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
return DefinitionGraphLibrary(
id="test",
version="0.1.0",
category_labels={"control": "Control"},
node_types=(
DefinitionNodeType(
type="start",
category="control",
label="Start",
description="Start",
icon="play",
),
DefinitionNodeType(
type="step",
category="control",
label="Step",
description="Step",
icon="square",
input_ports=(DefinitionPort(id="input", label="Input"),),
),
DefinitionNodeType(
type="end",
category="control",
label="End",
description="End",
icon="circle-stop",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(),
),
),
constraints=DefinitionGraphConstraints(
allow_cycles=allow_cycles,
node_counts=(
DefinitionNodeCountConstraint(
code="graph.start_count",
label="start",
minimum=1,
maximum=1,
node_types=("start",),
),
DefinitionNodeCountConstraint(
code="graph.end_count",
label="end",
minimum=1,
node_types=("end",),
),
),
),
)
class DefinitionGraphTests(unittest.TestCase):
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="end"),
),
)
self.assertEqual((), diagnostics)
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
nodes = (
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="retry", type="step"),
DefinitionNode(id="end", type="end"),
)
edges = (
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="retry"),
DefinitionEdge(id="edge-3", source="retry", target="step"),
DefinitionEdge(id="edge-4", source="retry", target="end"),
)
dag_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=False),
nodes=nodes,
edges=edges,
)
}
workflow_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=True),
nodes=nodes,
edges=edges,
)
}
self.assertIn("graph.cycle", dag_codes)
self.assertNotIn("graph.cycle", workflow_codes)
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(
id="edge",
source="start",
target="end",
target_port="missing",
),
),
)
self.assertTrue(
{"edge.unknown_target_port", "node.input_required"}.issubset(
{item.code for item in diagnostics}
)
)
if __name__ == "__main__":
unittest.main()