pipelantic 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipelantic/__init__.py +49 -0
- pipelantic/_version.py +3 -0
- pipelantic/contracts.py +42 -0
- pipelantic/diagnostics.py +89 -0
- pipelantic/exceptions.py +28 -0
- pipelantic/identity.py +47 -0
- pipelantic/inspection.py +18 -0
- pipelantic/mermaid.py +44 -0
- pipelantic/model.py +105 -0
- pipelantic/pipeline.py +723 -0
- pipelantic/ports.py +91 -0
- pipelantic/py.typed +0 -0
- pipelantic/refs.py +65 -0
- pipelantic/transformation.py +289 -0
- pipelantic/validation.py +406 -0
- pipelantic-0.1.0.dist-info/METADATA +134 -0
- pipelantic-0.1.0.dist-info/RECORD +19 -0
- pipelantic-0.1.0.dist-info/WHEEL +4 -0
- pipelantic-0.1.0.dist-info/licenses/LICENSE +21 -0
pipelantic/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pipelantic — typed, contract-driven data pipeline modeling.
|
|
2
|
+
|
|
3
|
+
0.1 provides the authoring model, logical graph construction, topology and
|
|
4
|
+
compatibility diagnostics, inspection, and Mermaid output.
|
|
5
|
+
|
|
6
|
+
Data contracts are provided by ContractModel. This package re-exports
|
|
7
|
+
``DataContractModel`` as an alias of ``contractmodel.ContractModel`` for
|
|
8
|
+
documentation-aligned imports.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pipelantic._version import __version__
|
|
12
|
+
from pipelantic.contracts import DataContractModel
|
|
13
|
+
from pipelantic.diagnostics import Diagnostic, Severity, ValidationReport
|
|
14
|
+
from pipelantic.exceptions import (
|
|
15
|
+
ModelDefinitionError,
|
|
16
|
+
PipelanticError,
|
|
17
|
+
PipelineValidationError,
|
|
18
|
+
)
|
|
19
|
+
from pipelantic.model import Edge, LogicalGraph, Node, NodeKind
|
|
20
|
+
from pipelantic.pipeline import Pipeline, Sink, Source, SubpipelineInstance
|
|
21
|
+
from pipelantic.ports import Input, Output, Parameter
|
|
22
|
+
from pipelantic.refs import OutputRef
|
|
23
|
+
from pipelantic.transformation import ImplementationRecord, Step, Transformation
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DataContractModel",
|
|
27
|
+
"Diagnostic",
|
|
28
|
+
"Edge",
|
|
29
|
+
"ImplementationRecord",
|
|
30
|
+
"Input",
|
|
31
|
+
"LogicalGraph",
|
|
32
|
+
"ModelDefinitionError",
|
|
33
|
+
"Node",
|
|
34
|
+
"NodeKind",
|
|
35
|
+
"Output",
|
|
36
|
+
"OutputRef",
|
|
37
|
+
"Parameter",
|
|
38
|
+
"PipelanticError",
|
|
39
|
+
"Pipeline",
|
|
40
|
+
"PipelineValidationError",
|
|
41
|
+
"Severity",
|
|
42
|
+
"Sink",
|
|
43
|
+
"Source",
|
|
44
|
+
"Step",
|
|
45
|
+
"SubpipelineInstance",
|
|
46
|
+
"Transformation",
|
|
47
|
+
"ValidationReport",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
pipelantic/_version.py
ADDED
pipelantic/contracts.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Data-contract integration boundary for ContractModel.
|
|
2
|
+
|
|
3
|
+
Pipelantic docs refer to ``DataContractModel``. The published ContractModel
|
|
4
|
+
package exposes ``ContractModel`` as the Pydantic authoring base. This module
|
|
5
|
+
aliases that type and provides helpers for identity and compatibility checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, TypeAlias
|
|
11
|
+
|
|
12
|
+
from contractmodel import ContractModel
|
|
13
|
+
|
|
14
|
+
# Docs-aligned alias for the ContractModel Pydantic authoring base.
|
|
15
|
+
DataContractModel: TypeAlias = ContractModel
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ContractModel",
|
|
19
|
+
"DataContractModel",
|
|
20
|
+
"is_data_contract_type",
|
|
21
|
+
"resolve_contract_type",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_data_contract_type(obj: Any) -> bool:
|
|
26
|
+
"""Return True when ``obj`` is a ContractModel-compatible data-contract class."""
|
|
27
|
+
return isinstance(obj, type) and issubclass(obj, ContractModel)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_contract_type(annotation: Any) -> type[Any] | None:
|
|
31
|
+
"""Extract a data-contract class from a type annotation when possible.
|
|
32
|
+
|
|
33
|
+
Returns ``None`` when the annotation is not a concrete ContractModel subclass.
|
|
34
|
+
"""
|
|
35
|
+
if is_data_contract_type(annotation):
|
|
36
|
+
return annotation
|
|
37
|
+
origin = getattr(annotation, "__origin__", None)
|
|
38
|
+
if origin is not None:
|
|
39
|
+
args = getattr(annotation, "__args__", ())
|
|
40
|
+
if len(args) == 1 and is_data_contract_type(args[0]):
|
|
41
|
+
return args[0]
|
|
42
|
+
return None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Structured diagnostics and validation reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pipelantic.exceptions import PipelineValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(StrEnum):
|
|
14
|
+
"""Diagnostic severity levels."""
|
|
15
|
+
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
INFO = "info"
|
|
19
|
+
HINT = "hint"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Diagnostic:
|
|
24
|
+
"""A structured finding from loading, inspection, or validation."""
|
|
25
|
+
|
|
26
|
+
code: str
|
|
27
|
+
severity: Severity
|
|
28
|
+
message: str
|
|
29
|
+
path: tuple[str, ...] = ()
|
|
30
|
+
help: str | None = None
|
|
31
|
+
related: tuple[tuple[str, ...], ...] = ()
|
|
32
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ValidationReport:
|
|
37
|
+
"""Immutable collection of diagnostics for a validation pass."""
|
|
38
|
+
|
|
39
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def valid(self) -> bool:
|
|
43
|
+
"""Return True when no error-severity diagnostics are present."""
|
|
44
|
+
return not any(d.severity is Severity.ERROR for d in self.diagnostics)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def errors(self) -> tuple[Diagnostic, ...]:
|
|
48
|
+
"""Return error-severity diagnostics."""
|
|
49
|
+
return tuple(d for d in self.diagnostics if d.severity is Severity.ERROR)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def warnings(self) -> tuple[Diagnostic, ...]:
|
|
53
|
+
"""Return warning-severity diagnostics."""
|
|
54
|
+
return tuple(d for d in self.diagnostics if d.severity is Severity.WARNING)
|
|
55
|
+
|
|
56
|
+
def filter(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
severity: Severity | None = None,
|
|
60
|
+
code: str | None = None,
|
|
61
|
+
) -> ValidationReport:
|
|
62
|
+
"""Return a report containing only matching diagnostics."""
|
|
63
|
+
items = self.diagnostics
|
|
64
|
+
if severity is not None:
|
|
65
|
+
items = tuple(d for d in items if d.severity is severity)
|
|
66
|
+
if code is not None:
|
|
67
|
+
items = tuple(d for d in items if d.code == code)
|
|
68
|
+
return ValidationReport(diagnostics=items)
|
|
69
|
+
|
|
70
|
+
def raise_for_errors(self) -> None:
|
|
71
|
+
"""Raise :class:`PipelineValidationError` if the report is invalid."""
|
|
72
|
+
if self.valid:
|
|
73
|
+
return
|
|
74
|
+
count = len(self.errors)
|
|
75
|
+
message = f"Pipeline validation failed with {count} error(s)."
|
|
76
|
+
raise PipelineValidationError(message, report=self)
|
|
77
|
+
|
|
78
|
+
def merge(self, other: ValidationReport) -> ValidationReport:
|
|
79
|
+
"""Combine two reports, preserving order and uniqueness by identity."""
|
|
80
|
+
return ValidationReport(diagnostics=self.diagnostics + other.diagnostics)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_diagnostics(cls, diagnostics: Iterable[Diagnostic]) -> ValidationReport:
|
|
84
|
+
"""Build a report from an iterable of diagnostics."""
|
|
85
|
+
return cls(diagnostics=tuple(diagnostics))
|
|
86
|
+
|
|
87
|
+
def codes(self) -> Sequence[str]:
|
|
88
|
+
"""Return diagnostic codes in report order."""
|
|
89
|
+
return tuple(d.code for d in self.diagnostics)
|
pipelantic/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Public exception hierarchy for Pipelantic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pipelantic.diagnostics import ValidationReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PipelanticError(Exception):
|
|
12
|
+
"""Base class for public Pipelantic exceptions."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelDefinitionError(PipelanticError):
|
|
16
|
+
"""Raised when a class definition cannot form a usable model."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PipelineValidationError(PipelanticError):
|
|
20
|
+
"""Raised when validation fails and the caller requested an exception."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, *, report: ValidationReport) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.report = report
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InternalPipelanticError(PipelanticError):
|
|
28
|
+
"""Raised when an internal invariant is violated."""
|
pipelantic/identity.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Stable identity helpers for pipelines, nodes, ports, and contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def qualified_type_id(obj: type[Any] | Any) -> str:
|
|
9
|
+
"""Return a stable identity for a class or object type.
|
|
10
|
+
|
|
11
|
+
Format: ``{module}:{qualname}``. Never uses memory addresses.
|
|
12
|
+
"""
|
|
13
|
+
if not isinstance(obj, type):
|
|
14
|
+
obj = type(obj)
|
|
15
|
+
module = getattr(obj, "__module__", None) or "<unknown>"
|
|
16
|
+
qualname = getattr(obj, "__qualname__", None) or getattr(obj, "__name__", "?")
|
|
17
|
+
return f"{module}:{qualname}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pipeline_id(pipeline_cls: type[Any]) -> str:
|
|
21
|
+
"""Stable identity for a pipeline class."""
|
|
22
|
+
return qualified_type_id(pipeline_cls)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def transformation_id(transformation_cls: type[Any]) -> str:
|
|
26
|
+
"""Stable identity for a transformation class."""
|
|
27
|
+
return qualified_type_id(transformation_cls)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def contract_id(contract_type: type[Any]) -> str:
|
|
31
|
+
"""Stable identity for a data-contract type."""
|
|
32
|
+
return qualified_type_id(contract_type)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def node_id(pipeline: str, node_name: str) -> str:
|
|
36
|
+
"""Stable identity for a node within a pipeline."""
|
|
37
|
+
return f"{pipeline}/{node_name}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def port_id(node: str, port_name: str) -> str:
|
|
41
|
+
"""Stable identity for a port on a node."""
|
|
42
|
+
return f"{node}:{port_name}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def implementation_id(transform: str, engine: str) -> str:
|
|
46
|
+
"""Stable identity for a registered transformation implementation."""
|
|
47
|
+
return f"{transform}/{engine}"
|
pipelantic/inspection.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pipeline inspection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pipelantic.model import LogicalGraph
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pipelantic.pipeline import Pipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inspect_pipeline(pipeline_cls: type[Pipeline]) -> LogicalGraph:
|
|
14
|
+
"""Return the immutable logical graph for a pipeline class.
|
|
15
|
+
|
|
16
|
+
Repeated calls return an equivalent graph (cached on the class).
|
|
17
|
+
"""
|
|
18
|
+
return pipeline_cls.build_graph()
|
pipelantic/mermaid.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Mermaid diagram generation from logical graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pipelantic.model import LogicalGraph, NodeKind
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def graph_to_mermaid(graph: LogicalGraph) -> str:
|
|
9
|
+
"""Render a logical pipeline graph as a Mermaid flowchart.
|
|
10
|
+
|
|
11
|
+
Output is deterministic for a given graph.
|
|
12
|
+
"""
|
|
13
|
+
lines: list[str] = ["flowchart LR"]
|
|
14
|
+
node_ids: dict[str, str] = {}
|
|
15
|
+
|
|
16
|
+
for index, node in enumerate(graph.nodes):
|
|
17
|
+
safe_id = f"n{index}"
|
|
18
|
+
node_ids[node.name] = safe_id
|
|
19
|
+
label = _node_label(node.name, node.kind, node.transformation_name)
|
|
20
|
+
lines.append(f' {safe_id}["{label}"]')
|
|
21
|
+
|
|
22
|
+
for edge in graph.edges:
|
|
23
|
+
src = node_ids.get(edge.producer_node)
|
|
24
|
+
dst = node_ids.get(edge.consumer_node)
|
|
25
|
+
if src is None or dst is None:
|
|
26
|
+
continue
|
|
27
|
+
edge_label = edge.producer_port
|
|
28
|
+
if edge.producer_port != edge.consumer_port:
|
|
29
|
+
edge_label = f"{edge.producer_port}->{edge.consumer_port}"
|
|
30
|
+
lines.append(f" {src} -- {edge_label} --> {dst}")
|
|
31
|
+
|
|
32
|
+
return "\n".join(lines) + "\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _node_label(name: str, kind: NodeKind, transformation_name: str | None) -> str:
|
|
36
|
+
if kind is NodeKind.SOURCE:
|
|
37
|
+
return f"Source: {name}"
|
|
38
|
+
if kind is NodeKind.SINK:
|
|
39
|
+
return f"Sink: {name}"
|
|
40
|
+
if kind is NodeKind.SUBPIPELINE:
|
|
41
|
+
return f"Subpipeline: {name}"
|
|
42
|
+
if transformation_name:
|
|
43
|
+
return f"{name}\\n({transformation_name})"
|
|
44
|
+
return name
|
pipelantic/model.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Immutable logical graph intermediate representation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeKind(StrEnum):
|
|
13
|
+
"""Kinds of nodes in a logical pipeline graph."""
|
|
14
|
+
|
|
15
|
+
SOURCE = "source"
|
|
16
|
+
STEP = "step"
|
|
17
|
+
SINK = "sink"
|
|
18
|
+
SUBPIPELINE = "subpipeline"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class PortSpec:
|
|
23
|
+
"""A typed port on a logical node."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
direction: str # "input" | "output"
|
|
27
|
+
contract_type: type[Any] | None
|
|
28
|
+
contract_id: str | None
|
|
29
|
+
required: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ParameterSpec:
|
|
34
|
+
"""A typed parameter on a transformation step."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
value_type: type[Any] | None
|
|
38
|
+
default: Any = ...
|
|
39
|
+
has_default: bool = False
|
|
40
|
+
value: Any = ...
|
|
41
|
+
has_value: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Edge:
|
|
46
|
+
"""A data-flow edge from a producer port to a consumer port."""
|
|
47
|
+
|
|
48
|
+
producer_node: str
|
|
49
|
+
producer_port: str
|
|
50
|
+
consumer_node: str
|
|
51
|
+
consumer_port: str
|
|
52
|
+
producer_contract_id: str | None = None
|
|
53
|
+
consumer_contract_id: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Node:
|
|
58
|
+
"""A node in the logical pipeline graph."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
kind: NodeKind
|
|
62
|
+
identity: str
|
|
63
|
+
contract_type: type[Any] | None = None
|
|
64
|
+
contract_id: str | None = None
|
|
65
|
+
binding: str | None = None
|
|
66
|
+
transformation_id: str | None = None
|
|
67
|
+
transformation_name: str | None = None
|
|
68
|
+
inputs: tuple[PortSpec, ...] = ()
|
|
69
|
+
outputs: tuple[PortSpec, ...] = ()
|
|
70
|
+
parameters: tuple[ParameterSpec, ...] = ()
|
|
71
|
+
# For subpipelines: nested graph identity and public port names
|
|
72
|
+
nested_pipeline_id: str | None = None
|
|
73
|
+
nested_graph: LogicalGraph | None = None
|
|
74
|
+
metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class LogicalGraph:
|
|
79
|
+
"""Immutable, deterministic logical pipeline graph."""
|
|
80
|
+
|
|
81
|
+
pipeline_id: str
|
|
82
|
+
pipeline_name: str
|
|
83
|
+
nodes: tuple[Node, ...] = ()
|
|
84
|
+
edges: tuple[Edge, ...] = ()
|
|
85
|
+
metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
|
86
|
+
|
|
87
|
+
def node_map(self) -> dict[str, Node]:
|
|
88
|
+
"""Return nodes keyed by name in declaration order."""
|
|
89
|
+
return {node.name: node for node in self.nodes}
|
|
90
|
+
|
|
91
|
+
def node_names(self) -> tuple[str, ...]:
|
|
92
|
+
"""Return node names in declaration order."""
|
|
93
|
+
return tuple(node.name for node in self.nodes)
|
|
94
|
+
|
|
95
|
+
def edges_from(self, node_name: str) -> tuple[Edge, ...]:
|
|
96
|
+
"""Return edges whose producer is ``node_name``."""
|
|
97
|
+
return tuple(e for e in self.edges if e.producer_node == node_name)
|
|
98
|
+
|
|
99
|
+
def edges_to(self, node_name: str) -> tuple[Edge, ...]:
|
|
100
|
+
"""Return edges whose consumer is ``node_name``."""
|
|
101
|
+
return tuple(e for e in self.edges if e.consumer_node == node_name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Forward reference resolution for Node.nested_graph
|
|
105
|
+
Node.__annotations__["nested_graph"] = LogicalGraph | None
|