Source code for loom.etl.compiler._plan
"""Immutable ETL compiled plan types.
All plan objects are frozen dataclasses — zero mutation after compilation,
zero reflection at execution time.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from loom.etl.declarative.source import SourceSpec
from loom.etl.declarative.target import TargetSpec
[docs]
@dataclass(frozen=True)
class SourceBinding:
"""Compiled binding between a source alias and its normalized spec.
Args:
alias: Name matching the ``execute()`` keyword parameter.
spec: Normalized source specification.
"""
alias: str
spec: SourceSpec
[docs]
@dataclass(frozen=True)
class ConfigValueBinding:
"""Compiled binding between an ``execute()`` keyword and a config key.
Carries the declaration only; the value is resolved by the executor when
the step runs and is never stored on a plan.
Args:
alias: Name matching the ``execute()`` keyword parameter.
key: Dot-separated config path.
value_type: Type the resolved value is converted to.
"""
alias: str
key: str
value_type: object
[docs]
@dataclass(frozen=True)
class AgentBinding:
"""Compiled binding between an ``execute()`` keyword and a declared agent.
Args:
alias: Name matching the ``execute()`` keyword parameter.
name: Agent name.
output_type: Type every answer decodes into.
max_usd: Budget of one execution of the step, or ``None``.
"""
alias: str
name: str
output_type: type[Any]
max_usd: Decimal | None = None
[docs]
@dataclass(frozen=True)
class TargetBinding:
"""Compiled binding for the step target.
Args:
spec: Normalized target specification.
"""
spec: TargetSpec
[docs]
@dataclass(frozen=True)
class StepPlan:
"""Compiled plan for a single :class:`~loom.etl.ETLStep`.
Args:
step_type: The concrete ``ETLStep`` subclass.
params_type: The ``ParamsT`` generic argument.
source_bindings: Ordered source alias → spec bindings.
target_binding: Compiled target.
streaming: Whether the step opts into Polars streaming execution.
Defaults to ``False``.
config_bindings: Ordered ``FromConfig`` alias → key bindings.
agent_bindings: Ordered ``WithAgent`` alias → agent bindings.
"""
step_type: type[Any]
params_type: type[Any]
source_bindings: tuple[SourceBinding, ...]
target_binding: TargetBinding
streaming: bool = False
config_bindings: tuple[ConfigValueBinding, ...] = ()
agent_bindings: tuple[AgentBinding, ...] = ()
[docs]
@dataclass(frozen=True)
class ParallelStepGroup:
"""A group of :class:`StepPlan` items that execute concurrently.
Args:
plans: Step plans to run in parallel.
"""
plans: tuple[StepPlan, ...]
# A process node is either one step or a parallel group of steps.
ProcessStepNode = StepPlan | ParallelStepGroup
[docs]
@dataclass(frozen=True)
class ProcessPlan:
"""Compiled plan for an :class:`~loom.etl.ETLProcess`.
Args:
process_type: The concrete ``ETLProcess`` subclass.
params_type: The ``ParamsT`` generic argument.
nodes: Ordered sequence of step nodes (sequential or parallel groups).
"""
process_type: type[Any]
params_type: type[Any]
nodes: tuple[ProcessStepNode, ...]
[docs]
@dataclass(frozen=True)
class ParallelProcessGroup:
"""A group of :class:`ProcessPlan` items that execute concurrently.
Args:
plans: Process plans to run in parallel.
"""
plans: tuple[ProcessPlan, ...]
# A pipeline node is either one process or a parallel group of processes.
PipelineProcessNode = ProcessPlan | ParallelProcessGroup
[docs]
@dataclass(frozen=True)
class PipelinePlan:
"""Compiled plan for an :class:`~loom.etl.ETLPipeline`.
Args:
pipeline_type: The concrete ``ETLPipeline`` subclass.
params_type: The ``ParamsT`` generic argument.
nodes: Ordered sequence of process nodes.
"""
pipeline_type: type[Any]
params_type: type[Any]
nodes: tuple[PipelineProcessNode, ...]
[docs]
def iter_processes(plan: PipelinePlan) -> Iterator[ProcessPlan]:
"""Yield every concrete process plan in pipeline execution order.
Flattens top-level :class:`ParallelProcessGroup` nodes while preserving
the declared order within each group.
Args:
plan: Compiled pipeline plan.
Yields:
Each :class:`ProcessPlan` of *plan*, once.
"""
for node in plan.nodes:
match node:
case ProcessPlan():
yield node
case ParallelProcessGroup(plans=plans):
yield from plans
[docs]
def iter_steps_in_process(plan: ProcessPlan) -> Iterator[StepPlan]:
"""Yield every concrete step plan in process execution order.
Flattens :class:`ParallelStepGroup` nodes while preserving the declared
order within each group.
Args:
plan: Compiled process plan, e.g. one yielded by :func:`iter_processes`.
Yields:
Each :class:`StepPlan` of *plan*, once.
"""
for node in plan.nodes:
match node:
case StepPlan():
yield node
case ParallelStepGroup(plans=plans):
yield from plans
[docs]
def iter_all_steps(plan: PipelinePlan) -> Iterator[StepPlan]:
"""Yield every concrete step plan in a pipeline, in execution order.
Equivalent to :func:`iter_steps_in_process` over each process yielded by
:func:`iter_processes`.
Args:
plan: Compiled pipeline plan.
Yields:
Each :class:`StepPlan` of *plan*; a step class listed by two
processes is yielded once per process.
"""
for proc in iter_processes(plan):
yield from iter_steps_in_process(proc)
def visit_pipeline_nodes(
nodes: tuple[PipelineProcessNode, ...],
on_process: Callable[[ProcessPlan], None],
*,
on_parallel_group: Callable[[tuple[ProcessPlan, ...]], None] | None = None,
) -> None:
"""Visit pipeline nodes with optional custom handling for parallel groups.
Args:
nodes: Pipeline-level nodes to visit.
on_process: Callback for each concrete :class:`ProcessPlan`.
on_parallel_group: Optional callback for each
:class:`ParallelProcessGroup`. When omitted, groups are flattened
and ``on_process`` is called for every process in the group.
"""
for node in nodes:
match node:
case ProcessPlan():
on_process(node)
case ParallelProcessGroup(plans=plans):
if on_parallel_group is None:
for proc in plans:
on_process(proc)
else:
on_parallel_group(plans)
def visit_process_nodes(
nodes: tuple[ProcessStepNode, ...],
on_step: Callable[[StepPlan], None],
*,
on_parallel_group: Callable[[tuple[StepPlan, ...]], None] | None = None,
) -> None:
"""Visit process nodes with optional custom handling for parallel groups.
Args:
nodes: Process-level nodes to visit.
on_step: Callback for each concrete :class:`StepPlan`.
on_parallel_group: Optional callback for each
:class:`ParallelStepGroup`. When omitted, groups are flattened and
``on_step`` is called for every step in the group.
"""
for node in nodes:
match node:
case StepPlan():
on_step(node)
case ParallelStepGroup(plans=plans):
if on_parallel_group is None:
for step in plans:
on_step(step)
else:
on_parallel_group(plans)
[docs]
def flatten_step_names(
plan: PipelinePlan,
processes: tuple[str, ...] | None = None,
) -> tuple[str, ...]:
"""Flatten a compiled plan into the ordered tuple of step class names.
Steps are returned in their planned execution order (parallel groups
are flattened in declaration order). When *processes* is given, only
processes whose ``ProcessPlan.process_type.__name__`` is in that
tuple contribute their step names.
Args:
plan: The compiled pipeline plan.
processes: Optional whitelist of process class names. ``None``
(the default) includes every process.
Returns:
Ordered tuple of step class names ready to be passed to
:class:`~loom.etl.runner.ETLRunner.run` via ``include=``.
Example::
>>> from loom.etl.compiler import ETLCompiler, flatten_step_names
>>> plan = ETLCompiler().compile(MyPipeline)
>>> flatten_step_names(plan)
('ExtractStep', 'TransformStep', 'LoadStep')
"""
names: list[str] = []
def _add_process(proc: ProcessPlan) -> None:
if processes is not None and proc.process_type.__name__ not in processes:
return
def _add_step(step: StepPlan) -> None:
names.append(step.step_type.__name__)
visit_process_nodes(proc.nodes, _add_step)
visit_pipeline_nodes(plan.nodes, _add_process)
return tuple(names)