Source code for methodflow.pipeline

from __future__ import annotations

import copy
import inspect
import logging
from typing import Any, Callable, Literal, Optional

logger = logging.getLogger(__name__)


[docs]class ProcessStep: """Wrapper of Callable, with info to build the pipeline.""" def __init__( self, process_func: Callable, condition_attrs: list[str] = None ) -> None: self._func = process_func self._bound_func: Optional[Callable] = None self._condition_attrs = condition_attrs or [] self.predecessors: list[ProcessStep] = [] self.successors: list[ProcessStep] = [] self.to_run: bool = True self.color: Literal["w", "b", "g"] = "w"
[docs] def reset(self): self.predecessors = [] self.successors = [] self.to_run = True self.color = "w"
@property def func_arg_names(self) -> list[str]: return list(inspect.signature(self._func).parameters.keys()) @property def func(self) -> Callable: return self._bound_func or self._func def __hash__(self) -> int: return hash(self.func) def __repr__(self) -> str: return f"ProcessStep{self.func}" @property def condition_attrs(self) -> list[str]: return self._condition_attrs @property def in_degree(self) -> int: return len(self.predecessors) @property def out_degree(self) -> int: return len(self.successors) @property def out_to_run_degree(self) -> int: return sum(1 for _ in filter(lambda n: n.to_run, self.successors)) def __call__(self, *args, **kwargs) -> Any: return self.func(*args, **kwargs) def __set__(self, obj, value) -> None: """Make ProcessStep a data descriptor.""" raise AttributeError("Cannot change the value") def __get__(self, obj, objtype=None) -> ProcessStep: """Return the method wrapped.""" if hasattr(self._func, "__get__"): self._bound_func = self._func.__get__(obj, objtype) elif obj is not None: self._bound_func = lambda *args, **kwargs: self._func( obj or objtype, *args, **kwargs ) return self
[docs]class PipelineMixin: """Decorator and methods for building a processing pipeline."""
[docs] class StepSkipped: pass
[docs] @staticmethod def op(condition: Optional[str] = "") -> Callable[[Callable], ProcessStep]: """Decorator for registering a preprocess method. This needs to be the top-most decorator if using stacked decorators (e.g. @classmethod, @staticmethod). Args: condition (str): Comma separated string of attribute names used as predicates for the annotated method. The method will run if all the predicates are True at runtime. """ if condition is None: condition = "" if not isinstance(condition, str): raise ValueError("condition must be a comma separated string") condition = [s.strip() for s in condition.split(",") if len(s) > 0] def decorator(process_func: Callable): return ProcessStep(process_func, condition) return decorator
def _get_preprocess_steps(self) -> dict[str, ProcessStep]: return dict(inspect.getmembers(self, lambda m: isinstance(m, ProcessStep))) def _resolve_condition(self, step: ProcessStep) -> bool: if not isinstance(step, ProcessStep): raise RuntimeError("The given callable is not a processing step.") for condition_attr in step.condition_attrs: if not hasattr(self, condition_attr): logging.warning( "The owner class of the pipeline does not have the condition" f"attribute {condition_attr}, defaulting to True." ) elif not getattr(self, condition_attr): return False return True def _build_dag(self) -> list[ProcessStep]: """Build the DAG of processing methods. Returns: list[ProcessStep]: The starting nodes of the DAG. """ # mapping from names to nodes name_to_node = self._get_preprocess_steps() for node in name_to_node.values(): node.reset() # resolve the conditions for node in name_to_node.values(): node.to_run = self._resolve_condition(node) # resolve the connections for node in name_to_node.values(): for func_arg_name in node.func_arg_names: if not func_arg_name.startswith("results_from_"): continue previous_step_name = func_arg_name[13:] if previous_step_name not in name_to_node: raise RuntimeError( f"{previous_step_name} is not an available method." ) name_to_node[previous_step_name].successors.append(node) node.predecessors.append(name_to_node[previous_step_name]) start_nodes = list(filter(lambda n: n.in_degree == 0, name_to_node.values())) return start_nodes @staticmethod def _topological_sort(start_nodes: list[ProcessStep]) -> list[ProcessStep]: """Perform a topological sort on the pipeline DAG.""" pipeline = [] for start_node in start_nodes: if start_node.color == "b": continue stack = [start_node] while stack: node = stack[-1] if node.color == "w": node.color = "g" for next_node in node.successors: if next_node.color == "w" and next_node.to_run: stack.append(next_node) elif next_node.color == "g": raise RuntimeError("Cycle in graph") else: stack.pop() if node.color == "g" and node.to_run: node.color = "b" pipeline.append(node) return pipeline[::-1]
[docs] def execute_pipeline(self, *args, **kwargs) -> Any: """Execute the pipeline. The arguments are passed to the starting preprocess steps. """ pipeline = self._topological_sort(self._build_dag()) intermediate_results = {} output = [] for step in pipeline: if step.to_run: if step.in_degree == 0: logger.info(f"Executing {step}") intermediate_results[step] = step(*args, **kwargs) else: params_from = list( filter(lambda p: p in intermediate_results, step.predecessors) ) logger.info(f"Executing {step}, with outputs from {params_from}") params = [] for predecessor in step.predecessors: if predecessor.out_to_run_degree > 1: if predecessor in intermediate_results: params.append( copy.deepcopy(intermediate_results[predecessor]) ) else: params.append(PipelineMixin.StepSkipped) else: params.append( intermediate_results.get( predecessor, PipelineMixin.StepSkipped ) ) intermediate_results[step] = step(*params) if step.out_to_run_degree == 0: output.append(intermediate_results[step]) else: logger.info(f"Skipping {step}") intermediate_results[step] = PipelineMixin.StepSkipped return output[0] if len(output) == 1 else tuple(output)