Coverage for collectra/tasks/base.py: 87.50%
72 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Base task class for Collectra workflows.
3This module defines the fundamental task interface used throughout the Collectra system.
4It provides the base classes that all tasks must inherit from and implements the core task execution lifecycle.
6Classes:
7 Task: Base class for all workflow tasks
8"""
10__all__ = ["Task", "TaskNode"]
12import re
13from dataclasses import dataclass
14from itertools import product
15from pathlib import Path
16from typing import TYPE_CHECKING, Generic
18from collectra.commons import BaseEntity, Node, NodeStatus, T, TaskContext
19from collectra.cli import command
21if TYPE_CHECKING:
22 from collectra.pipelines.base import Collectra
25class Task(BaseEntity, Generic[T]):
26 """Abstract base class for all tasks in Collectra workflows.
28 Defines the common interface and behavior that all tasks must implement.
30 Attributes:
31 name (str): Unique identifier for the task within the workflow.
32 input (list[str]): List of input parameter names.
33 output (list[str]): List of output names.
34 context (TaskContext): Runtime context for task execution.
35 """
37 collective: bool = False
39 def __init__(self, name: str, **kwargs) -> None:
40 super().__init__(name)
41 self.pipeline: Collectra | None = None
42 self.context: TaskContext = TaskContext()
43 for key, value in kwargs.items():
44 setattr(self, key, value)
46 @property
47 def input_dict(self) -> dict[str, list]:
48 input_dict = dict()
49 if hasattr(self, "input"):
50 if not isinstance(self.input, list):
51 input_list = [self.input]
52 else:
53 input_list = self.input.copy()
54 input_dict = {k: [] for k in input_list}
55 return input_dict
57 def prepare_inputs(self, parents: list) -> list:
58 input_dict = self.input_dict
59 input_keys = list(input_dict.keys())
60 for parent in parents:
61 if parent.name in input_dict:
62 input_dict[parent.name].extend(parent.items.values())
64 if self.collective:
65 collective_entries = [
66 item for values in input_dict.values() for item in values
67 ]
68 return [collective_entries] if collective_entries else []
70 # Filter out empty inputs (READY but no items)
71 non_empty_dict = {k: v for k, v in input_dict.items() if v}
72 if not non_empty_dict:
73 return []
74 non_empty_keys = list(non_empty_dict.keys())
75 value_lists = list(non_empty_dict.values())
76 entries = [list(combination) for combination in list(product(*value_lists))]
78 entries_to_remove = []
79 for entry_id, entry in enumerate(entries):
80 entry_items_by_key = {f"{item.name}": item for item in entry}
81 is_valid = True
82 for item in entry:
83 for parent_id in item.parents:
84 parent_key = re.sub("-.*", "", parent_id)
85 if (
86 parent_key in non_empty_keys
87 ): # Only validate against non-empty inputs
88 if (
89 parent_key not in entry_items_by_key
90 or entry_items_by_key[parent_key].id != parent_id
91 ):
92 is_valid = False
93 break
94 if not is_valid:
95 entries_to_remove.append(entry_id)
97 entries = [
98 entry for id, entry in enumerate(entries) if id not in entries_to_remove
99 ]
100 return entries
102 def run(self, *args) -> T | None:
103 """Run the task execution logic.
105 This method contains the core task execution logic and must be
106 implemented by all subclasses.
108 """
109 raise NotImplementedError("Subclasses must implement this method.")
111 def get_output_name(self) -> str:
112 name = (
113 f"{self.get_name()}_output"
114 if not hasattr(self, "output")
115 else self.output[0] if isinstance(self.output, list) else self.output
116 )
117 return name
119 def __call__(self, *args) -> T | None:
120 """Run the task with the provided arguments.
122 Args:
123 **kwargs: Keyword arguments for task execution.
125 Returns:
127 """
128 return self.run(*args)
130 @command(name="run")
131 def cli_run(
132 self,
133 inputs: list[str],
134 output: Path | None = None,
135 verbose: bool = False,
136 usage: bool = False,
137 render: bool = False,
138 ) -> None:
139 """Run this task for the supplied input files."""
140 if self.pipeline is None:
141 raise RuntimeError(f"Task {self.name!r} is not attached to a pipeline")
142 self.pipeline.cli_run(
143 inputs,
144 task=self.name,
145 output=output,
146 single=True,
147 verbose=verbose,
148 usage=usage,
149 render=render,
150 )
153@dataclass
154class TaskNode(Node):
156 task: Task
158 def get_task(self) -> Task:
159 return self.task