Coverage for packages/dqm-ml-job/src/dqm_ml_job/outputwriter/parquet.py: 83%
99 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-21 08:27 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-21 08:27 +0000
1"""Parquet output writer for persisting pipeline results.
3This module contains the ParquetOutputWriter class that writes
4metrics and features to Parquet files.
5"""
7import logging
8import os
9from pathlib import Path
10from typing import Any
12from dqm_ml_core.models.global_ import StorageConfig
13from dqm_ml_core.models.outputs import ParquetOutputConfig
14import pyarrow as pa
15import pyarrow.parquet as pq
17from dqm_ml_job.utils.s3 import get_s3_filesystem
19logger = logging.getLogger(__name__)
22class ParquetOutputWriter:
23 """
24 Output writer that saves processed features to a Parquet file.
25 """
27 def __init__(self, name: str, config: dict[str, Any] | None = None):
28 """
29 Initialize a ParquetOutputWriter.
31 Args:
32 name: Unique name for this output writer.
33 config: Configuration dictionary with keys:
34 - path_pattern (str): Output file path format string.
35 - columns (List[str]): Columns to save.
36 - storage (bool or dict, optional): Storage configuration.
37 If dict with type "s3", can contain access_key, secret_key, and endpoint_override.
39 Raises:
40 ValueError: If required config keys are missing.
41 """
42 cfg = ParquetOutputConfig.model_validate(config or {})
44 self.path_pattern = cfg.path_pattern
45 self.columns = list(cfg.columns)
46 self.exclude = list(cfg.exclude)
47 self.name = name
48 self.s3_filesystem = None
50 self._accumulate = "{}" not in self.path_pattern
51 self._accumulated_features: dict[str, list[pa.Array]] = {}
52 storage_cfg = cfg.storage
53 if storage_cfg: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 storage_config = StorageConfig.model_validate(storage_cfg)
55 if storage_config.type == "s3":
56 self.s3_filesystem = get_s3_filesystem(storage_config)
58 @staticmethod
59 def _collect_metric(metric_name: str, metrics_dict: dict[str, dict[str, Any]], keys: list[str]) -> pa.Array | None:
60 values = []
61 for key in keys:
62 val = metrics_dict[key][metric_name]
63 if isinstance(val, pa.FixedSizeListArray):
64 return None
65 if isinstance(val, pa.Array):
66 values.extend(val.to_pylist())
67 else:
68 values.append(val)
69 return pa.array(values)
71 def write_metrics_dict(self, metrics_dict: dict[str, dict[str, Any]]) -> None:
72 """Aggregate and write dataset-level metrics for all selections.
74 Args:
75 metrics_dict: Map of selection names to their computed
76 metric dictionaries.
77 """
78 if len(metrics_dict) <= 0: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 return
80 logger.debug(f"Writing metrics for the {len(metrics_dict)} data selections")
81 keys = list(metrics_dict.keys())
82 metric_names = list(metrics_dict[keys[0]].keys())
83 metrics_table = {"selection": pa.array(keys)}
84 for metric_name in metric_names:
85 if metric_name.startswith("__") and metric_name.endswith("__"):
86 continue
87 col = self._collect_metric(metric_name, metrics_dict, keys)
88 if col is not None:
89 metrics_table[metric_name] = col
90 self.write_table("", metrics_table)
92 def _format_filename(self, path_pattern: str, part: int | None = None) -> str:
93 """Format the output filename from the path pattern and optional part."""
94 if part is None:
95 return self.path_pattern.format(path_pattern, "")
96 return self.path_pattern.format(path_pattern, part)
98 def _write_local(self, table: pa.Table, filename: str) -> None:
99 """Write a table to a local file, creating the parent directory if needed."""
100 output_dir = Path(filename).parent
101 if not Path.exists(output_dir):
102 logger.info(f"Creating output directory: {output_dir}")
103 Path.mkdir(output_dir, parents=True, exist_ok=True)
104 pq.write_table(table, filename)
105 logger.info(f"Wrote output table to {filename}")
107 def write_table(
108 self,
109 path_pattern: str,
110 features_array: dict[str, Any],
111 part: int | None = None,
112 ) -> None:
113 """Write a table of features or metrics to a Parquet file.
115 Handles directory creation if the target path doesn't exist
116 (for local writes).
118 Args:
119 path_pattern: Identifier for the data destination.
120 features_array: Map of column names to pyarrow Arrays.
121 part: Optional partition index for chunked output.
122 """
124 for key in self.columns:
125 if key not in features_array:
126 logger.error(f"Missing {key} in features for output")
128 # Accumulate mode: buffer features for a single flush at the end
129 if self._accumulate:
130 for k, v in features_array.items():
131 if isinstance(v, pa.ChunkedArray):
132 v = v.combine_chunks()
133 self._accumulated_features.setdefault(k, []).append(v)
134 return
136 table = pa.table(features_array)
137 filename = self._format_filename(path_pattern, part)
138 if self.s3_filesystem is not None: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 self._write_to_s3(table, filename)
140 else:
141 self._write_local(table, filename)
143 def flush(self) -> None:
144 """Write all accumulated features to the output file.
146 Called after all selections have been processed in accumulate mode.
147 Does nothing if there are no accumulated features or if not
148 in accumulate mode.
149 """
150 if not self._accumulated_features:
151 return
153 final = {k: pa.concat_arrays(v) for k, v in self._accumulated_features.items()}
154 table = pa.table(final)
155 filename = self.path_pattern
157 if self.s3_filesystem is not None: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 s3_path = self._get_s3_path(filename)
159 pq.write_table(table, s3_path, filesystem=self.s3_filesystem)
160 logger.info(f"Wrote accumulated output table to S3: {s3_path}")
161 else:
162 self._write_local(table, filename)
164 self._accumulated_features.clear()
166 def _get_s3_path(self, file_path: str) -> str:
167 """Construct an S3 path by combining the bucket name with a file path.
169 Args:
170 file_path: The file path within the bucket.
172 Returns:
173 str: The full S3 path in format "bucket_name/file_path".
174 """
175 bucket_name = os.getenv("S3_BUCKET_NAME", "")
176 return bucket_name + "/" + file_path
178 def _write_to_s3(self, table: pa.Table, filename: str) -> None:
179 """Write a PyArrow table to S3.
181 Args:
182 table: The table to write.
183 filename: The file path within the bucket.
184 """
185 s3_path = self._get_s3_path(filename)
186 try:
187 pq.write_table(table, s3_path, filesystem=self.s3_filesystem)
188 logger.info(f"Wrote output table to S3: {s3_path}")
189 except Exception:
190 logger.exception("Failed to write to S3")
191 raise