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 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
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)
@@ -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}"
@@ -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