Coverage for collectra/pipelines/handlers.py: 0.00%
126 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"""Artefact handling and persistence classes for Collectra workflows.
3This module provides specialized handlers for saving workflow configurations
4and artifacts to different storage formats. It supports both directory-based
5and zip-based storage options with proper model artifact management.
7The handlers manage:
8 - Workflow configuration serialization
9 - Machine learning model artifact storage
10 - File system operations for different output formats
11 - Model versioning and cleanup operations
13Classes:
14 DataHandler: Abstract base class for data handling operations
15 DirectoryHandler: Handler for directory-based workflow storage
16 ZipHandler: Handler for zip-based workflow storage
17"""
19import os
20import shutil
21import tempfile
22import zipfile
23from dataclasses import dataclass
24from datetime import datetime
25from pathlib import Path
27import yaml
29from collectra.pipelines.base import Collectra
30from collectra.tasks.base import Task
31from collectra.utils import error_msg, processing_msg, success_msg
34@dataclass(kw_only=True)
35class DataHandler:
36 """Abstract base class for workflow data handling operations.
38 Provides common functionality for saving workflow configurations and
39 managing machine learning model artifacts across different storage formats.
41 Attributes:
42 pipeline (Collectra): The workflow pipeline to save.
43 """
45 pipeline: Collectra
47 def save(self):
48 """Save the workflow configuration and artifacts.
50 Raises:
51 NotImplementedError: This method must be implemented by subclasses.
52 """
53 raise NotImplementedError(
54 "If this is being called, the incorrect handler is not assigned."
55 )
57 def get_models(self, task: Task) -> tuple[str, str]:
58 """Extract current and previous model paths from a machine learning task.
60 Args:
61 task: A task exposing a model attribute.
63 Returns:
64 tuple[str, str]: Current model path and old model path.
65 """
66 task_config = self.pipeline.data.get(task.name, {})
67 return str(getattr(task, "model", "") or ""), str(
68 task_config.get("old_model", "")
69 )
71 def has_model(self, task: Task) -> bool:
72 """Return whether a task exposes a model artifact.
74 Args:
75 task: Task to inspect.
77 Returns:
78 bool: True if the task is a machine learning task, False otherwise.
79 """
80 if not hasattr(task, "model"):
81 error_message = f"Task {task.name} does not expose a model. Skipping..."
82 print(error_msg(error_message))
83 return False
84 return True
86 def _save_config(self, tmp_dir: Path) -> None:
87 """Save the workflow configuration to a YAML file.
89 Args:
90 tmp_dir (Path): Temporary directory to save the configuration file.
91 """
92 pipeline_yaml = tmp_dir / "pipeline.yaml"
93 data = self.pipeline.get_config()
94 with open(pipeline_yaml, "w") as f:
95 for key in data:
96 f.write(
97 yaml.dump(
98 {key: data[key]}, default_flow_style=False, sort_keys=False
99 )
100 )
101 f.write("\n")
104@dataclass(kw_only=True)
105class DirectoryHandler(DataHandler):
106 """Handler for saving workflows as directory structures.
108 Saves workflow configurations and artifacts to a directory structure,
109 making them easy to inspect and modify manually.
110 """
112 def save(self):
113 """Save the workflow and its artifacts to a directory structure.
115 Creates a temporary directory for staging files, then moves them to
116 the final output directory location.
117 """
118 with tempfile.TemporaryDirectory() as tmpdirname:
119 out_dir = self.pipeline.get_out_dir()
120 tmp_dir = Path(tmpdirname)
121 self._save_artifacts(tmp_dir)
122 self._save_config(tmp_dir)
123 out_dir.mkdir(
124 parents=True, exist_ok=True
125 ) # Ensure the output directory exists
126 for item in tmp_dir.iterdir():
127 shutil.move(item, out_dir / item.name)
128 success_message = (
129 f"Workflow {self.pipeline.name} was saved successfully to {out_dir}"
130 )
131 print(success_msg(success_message))
133 def _save_artifacts(self, tmp_dir: Path) -> None:
134 """Save machine learning model artifacts to the temporary directory.
136 Processes all machine learning tasks in the pipeline to save their model
137 artifacts. Handles model versioning, file movement, and cleanup of old models.
139 Args:
140 tmp_dir (Path): Temporary directory to save artifacts to.
141 """
142 old_models: dict[str, Path] = dict()
143 for task_item in self.pipeline.tasks:
144 if not self.has_model(task_item):
145 continue
146 task = task_item
147 model, old_model = self.get_models(task)
148 if not model:
149 error_message = f"Task {task.name} does not have a model associated with it. Skipping..."
150 print(error_msg(error_message))
151 continue
152 # Ensure the model path is a Path object
153 model_path = Path(model)
154 task_model_path = f"{task.name}-{datetime.now().strftime('%Y%m%d_%H%M%S')}-best.pt" # Default model path for the task
155 new_model = old_model != model
156 if not (model_path.exists() and model_path.is_file()):
157 error_message = f"Model for {task.name} does not point to a valid file or doesn't exist: {model_path}. Attempting to download..."
158 print(error_msg(error_message))
159 task.load(model_path.name)
160 model_path = Path(model_path.name)
161 new_model = True
162 if new_model:
163 print(
164 f"Moving model {model_path} to temporary directory with new name {task_model_path}..."
165 )
166 shutil.move(model_path, tmp_dir / task_model_path)
167 if old_model and Path(old_model).exists():
168 old_models[old_model] = Path(old_model)
169 self.pipeline.data[task.name]["model"] = task_model_path
170 else:
171 old_models.pop(model, None)
172 processing_message = f"Model {model_path} already exists. Copying to temporary directory..."
173 print(processing_msg(processing_message))
174 shutil.copy(model_path, tmp_dir / model_path.name)
175 self.pipeline.data[task.name]["model"] = model_path.name
177 for old_model in old_models:
178 if old_models[old_model].exists():
179 print(f"Removing old model {old_models[old_model]}...")
180 os.remove(old_models[old_model])
183@dataclass(kw_only=True)
184class ZipHandler(DataHandler):
185 """Handler for saving workflows as compressed zip files.
187 Saves workflow configurations and artifacts to a single zip file,
188 making them portable and easy to distribute.
189 """
191 def save(self):
192 """Save the workflow and its artifacts to a compressed zip file.
194 Creates a temporary directory for staging files, then compresses
195 them into a single zip file at the output location.
197 Raises:
198 ValueError: If the output directory is not specified.
199 """
200 with tempfile.TemporaryDirectory() as tmpdirname:
201 if not self.pipeline.out_dir:
202 raise ValueError("Output directory is not specified.")
203 tmp_dir = Path(tmpdirname)
204 self._save_artifacts(tmp_dir)
205 self._save_config(tmp_dir)
206 with zipfile.ZipFile(
207 f"{self.pipeline.out_dir}", "w", zipfile.ZIP_DEFLATED, allowZip64=True
208 ) as zipf:
209 for root, _, files in os.walk(tmp_dir):
210 for file in files:
211 zipf.write(os.path.join(root, file), file)
212 processing_message = (
213 f"Saved {file} to {Path(self.pipeline.out_dir) / file}"
214 )
215 print(processing_msg(processing_message))
216 success_message = f"Workflow {self.pipeline.name} was saved successfully at {self.pipeline.out_dir}"
217 print(success_msg(success_message))
219 def _save_artifacts(self, tmp_dir: Path) -> None:
220 """Save machine learning model artifacts to the temporary directory for zip compression.
222 Processes all machine learning tasks in the pipeline to save their model
223 artifacts for zip archive creation. Handles model extraction from existing
224 zip files and version management.
226 Args:
227 tmp_dir (Path): Temporary directory to save artifacts to.
229 Raises:
230 ValueError: If the output directory is not specified.
231 """
232 if not self.pipeline.out_dir:
233 raise ValueError("Output directory is not specified.")
234 for task_item in self.pipeline.tasks:
235 if not self.has_model(task_item):
236 continue
237 task = task_item
238 model, old_model = self.get_models(task)
239 if not model:
240 error_message = f"Task {task.name} does not have a model associated with it. Skipping..."
241 print(error_msg(error_message))
242 continue
243 model_path = Path(model)
244 task_model_path = (
245 f"{task.name}-{datetime.now().strftime('%Y%m%d_%H%M%S')}-best.pt"
246 )
247 new_model: bool = old_model != model
248 with zipfile.ZipFile(self.pipeline.out_dir, "r") as zipf:
249 if not model_path.name in zipf.namelist():
250 task.load(model_path.name)
251 model_path = Path(model_path.name)
252 new_model = True
253 if new_model:
254 print(
255 f"Moving model {model_path} to temporary directory with new name {task_model_path}..."
256 )
257 shutil.move(model_path, tmp_dir / task_model_path)
258 self.pipeline.data[task.name]["model"] = task_model_path
259 else:
260 processing_message = f"Model {model_path} already exists. Copying to temporary directory..."
261 zipf.extract(str(model_path.name), tmp_dir)
262 print(processing_msg(processing_message))
263 shutil.copy(model_path, tmp_dir / model_path.name)
264 self.pipeline.data[task.name]["model"] = model_path.name