Coverage for packages/dqm-ml-job/src/dqm_ml_job/dataloaders/parquet.py: 90%

159 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-21 08:27 +0000

1"""Parquet data loader for reading Parquet files. 

2 

3This module contains the ParquetDataLoader and ParquetDataSelection classes 

4for loading and iterating over Parquet file data. 

5""" 

6 

7import fnmatch 

8import logging 

9import os 

10from typing import Any 

11 

12from dqm_ml_core.utils.matching import has_pattern, resolve_include_exclude, resolve_patterns 

13import pyarrow as pa 

14import pyarrow.compute as pc 

15import pyarrow.fs as fs 

16import pyarrow.parquet as pq 

17 

18# COMPATIBILITY : from typing import Any, override # When support of 3.10 and 3.11 will be removed 

19from typing_extensions import override 

20 

21from dqm_ml_job.dataloaders.proto import DataSelection 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26def _fnmatch_to_regex(pattern: str) -> str: 

27 """Convert fnmatch pattern to regex pattern. 

28 

29 Args: 

30 pattern: fnmatch pattern with * and ? wildcards. 

31 

32 Returns: 

33 Regex pattern string. 

34 """ 

35 return fnmatch.translate(pattern) 

36 

37 

38def _match_wildcard_arrow(col_expr: Any, patterns: list[str]) -> Any: 

39 """Return pyarrow expression for wildcard matching. 

40 

41 Args: 

42 col_expr: pyarrow field expression. 

43 patterns: List of fnmatch patterns. 

44 

45 Returns: 

46 pyarrow compute expression for OR of all patterns. 

47 """ 

48 regex_patterns = [_fnmatch_to_regex(p) for p in patterns] 

49 # Combine with OR 

50 combined_regex = "|".join(f"({p})" for p in regex_patterns) 

51 return pc.match_substring_regex(col_expr, combined_regex) 

52 

53 

54def _resolve_pyarrow_type(type_name: str) -> Any: 

55 """Map TransformType string to pyarrow DataType.""" 

56 import pyarrow as pa 

57 

58 mapping = { 

59 "int32": pa.int32(), 

60 "int64": pa.int64(), 

61 "float32": pa.float32(), 

62 "float64": pa.float64(), 

63 "bool": pa.bool_(), 

64 "str": pa.utf8(), 

65 "categorical": pa.dictionary(pa.int32(), pa.utf8()), 

66 } 

67 return mapping[type_name] 

68 

69 

70class ParquetDataSelection(DataSelection): 

71 """A specific selection of data from a Parquet dataset. 

72 

73 This class represents a filtered subset of a Parquet dataset 

74 and provides an iterator over PyArrow RecordBatches. 

75 

76 Attributes: 

77 name: Name identifier for this selection. 

78 path: Path to the Parquet file or directory. 

79 batch_size: Number of rows per batch. 

80 threads: Number of threads for parallel reading. 

81 filters_dict: Optional dictionary of column filters to apply. 

82 filesystem: Optional PyArrow filesystem for reading. 

83 sample_path: List of sample path configs describing column path prefixes. 

84 transforms: List of transform configs (column cast operations). 

85 """ 

86 

87 def __init__( 

88 self, 

89 name: str, 

90 path: str, 

91 batch_size: int = 100_000, 

92 threads: int = 4, 

93 filters_dict: dict[str, Any] | None = None, 

94 filesystem: Any | None = None, 

95 sample_path: list[dict[str, Any]] | None = None, 

96 transforms: list[dict[str, Any]] | None = None, 

97 ): 

98 """Initialize a Parquet data selection. 

99 

100 Args: 

101 name: Name identifier for this selection. 

102 path: Path to the Parquet file or directory. 

103 batch_size: Number of rows per batch (default: 100000). 

104 threads: Number of threads for parallel reading (default: 4). 

105 filters_dict: Optional dictionary of column filters to apply. 

106 filesystem: Optional PyArrow filesystem for reading. 

107 sample_path: List of sample path configs describing column path prefixes. 

108 transforms: List of transform configs (column cast operations). 

109 """ 

110 self.name = name 

111 self.path = path 

112 self.batch_size = batch_size 

113 self.threads = threads 

114 self.filters_dict = filters_dict 

115 self.filesystem = filesystem 

116 self.sample_path = sample_path or [] 

117 self.transforms = transforms or [] 

118 self.columns_list: list[str] | None = None 

119 self.dataset: pq.ParquetDataset | None = None 

120 self.samples_count: int = 0 

121 

122 def _build_filter_expr(self) -> Any: 

123 """Build a PyArrow filter expression from the filters dictionary. 

124 

125 Converts the filters_dict configuration into a combined pyarrow 

126 compute expression using AND logic across all filter conditions. 

127 

128 Returns: 

129 PyArrow compute expression for filtering, or None if no filters. 

130 """ 

131 if self.filters_dict is None: 

132 return None 

133 expr = None 

134 for col, val in self.filters_dict.items(): 

135 if self.columns_list is None: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 self.columns_list = [col] 

137 elif col not in self.columns_list: 

138 self.columns_list.append(col) 

139 from dqm_ml_job.dataloaders.filters import build_filter_condition 

140 

141 col_expr = build_filter_condition( 

142 col, 

143 val, 

144 wildcard_fn=lambda c, vals: _match_wildcard_arrow(pc.field(c), vals), 

145 isin_fn=lambda c, vals: pc.is_in(pc.field(c), pa.array(vals)), 

146 equal_fn=lambda c, v: pc.equal(pc.field(c), v), 

147 ) 

148 expr = col_expr if expr is None else (expr & col_expr) 

149 return expr 

150 

151 @override 

152 def bootstrap(self, columns_list: list[str]) -> None: 

153 """Initialize the parquet dataset and filter expression. 

154 

155 Args: 

156 columns_list: Names of columns to load from the parquet file. 

157 Empty list means read all columns. 

158 """ 

159 self.columns_list = columns_list or None 

160 logger.debug(f"[DEBUG] ParquetDataSelection.bootstrap: {self.name} received columns_list = {columns_list}") 

161 self.filter_expr = self._build_filter_expr() 

162 logger.debug(f"[DEBUG] ParquetDataSelection.bootstrap: filter_expr = {self.filter_expr}") 

163 self.dataset = pq.ParquetDataset(self.path, filters=self.filter_expr, filesystem=self.filesystem) 

164 if len(self.dataset.fragments) > 0: 

165 self.samples_count = sum(p.count_rows() for p in self.dataset.fragments) 

166 else: 

167 self.samples_count = 0 

168 

169 def __len__(self) -> int: 

170 return int(self.samples_count) 

171 

172 @override 

173 def get_nb_batches(self) -> int: 

174 """Return the estimated number of batches in this selection. 

175 

176 Returns: 

177 Number of batches based on total samples and batch size. 

178 """ 

179 return int(len(self) / self.batch_size) + (len(self) % self.batch_size > 0) 

180 

181 @override 

182 def __iter__(self) -> Any: 

183 if self.dataset is None: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 return 

185 logger.debug(f"[DEBUG] ParquetDataSelection.__iter__: {self.name} using columns_list = {self.columns_list}") 

186 for file in self.dataset.files: 

187 parquet_file = pq.ParquetFile(file, filesystem=self.filesystem) 

188 batch_iterator = parquet_file.iter_batches( 

189 batch_size=self.batch_size, 

190 columns=self.columns_list, 

191 use_threads=self.threads, 

192 ) 

193 for batch in batch_iterator: 

194 if self.filter_expr is not None: 

195 batch = batch.filter(self.filter_expr) 

196 if len(batch) == 0: 

197 continue 

198 batch = self._apply_transforms(batch) 

199 logger.debug( 

200 "[DEBUG] ParquetDataSelection.__iter__: %s yielded batch with columns = %s", 

201 self.name, 

202 batch.schema.names, 

203 ) 

204 yield batch 

205 

206 def _apply_transforms(self, batch: pa.RecordBatch) -> pa.RecordBatch: 

207 """Apply column transforms (cast operations) to the batch. 

208 

209 For each transform entry: 

210 - If ``in_place``, overwrite the column in-place. 

211 - Otherwise, append a new column named ``<column>_<to_type>``. 

212 """ 

213 for t in self.transforms: 

214 col_idx = batch.schema.get_field_index(t["column"]) 

215 if col_idx == -1: 

216 continue 

217 target_type = _resolve_pyarrow_type(t["to_type"]) 

218 cast_col = batch.column(col_idx).cast(target_type) 

219 if t.get("in_place", False): 

220 batch = batch.set_column(col_idx, t["column"], cast_col) 

221 else: 

222 new_name = f"{t['column']}_{t['to_type']}" 

223 batch = batch.append_column(pa.field(new_name, target_type), cast_col) 

224 return batch 

225 

226 @override 

227 def __repr__(self) -> str: 

228 return f"ParquetSelection(name='{self.name}', path='{self.path}', filters={self.filters_dict})" 

229 

230 

231class ParquetDataLoader: 

232 """Data loader for Parquet files that generates one or more DataSelections. 

233 

234 This loader can read from a single Parquet file or a directory of Parquet 

235 files, optionally splitting the data by a column value to create multiple 

236 selections. 

237 

238 Attributes: 

239 type: The loader type identifier ("parquet"). 

240 filesystem: Optional PyArrow filesystem for reading. 

241 """ 

242 

243 type: str = "parquet" 

244 

245 def __init__(self, name: str, config: dict[str, Any] | None = None): 

246 """Initialize the Parquet data loader. 

247 

248 Args: 

249 name: Unique name for this loader instance. 

250 config: Configuration dictionary containing: 

251 - path: Path to Parquet file or directory (required) 

252 - batch_size: Rows per batch (default: 100000) 

253 - threads: Number of threads (default: 4) 

254 - split_by: Column name to split selections by 

255 - split_values: Specific values to split on 

256 - filter.: list of filters 

257 - storage: Storage configuration (bool or dict) 

258 

259 Raises: 

260 ValueError: If required config keys are missing. 

261 """ 

262 if config is None: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 config = {} 

264 self.name = name 

265 self.config = config 

266 self.path: str = config["path"] 

267 self.batch_size = config.get("batch_size", 100_000) 

268 self.threads = config.get("threads", 4) 

269 # Use SplitConfig model fields instead of hardcoded keys 

270 from dqm_ml_core.models.dataloaders import SplitConfig 

271 

272 split = config.get("split") 

273 self.split = SplitConfig.model_validate(split) if split else None 

274 self.split_by = self.split.by if self.split else None 

275 self.split_values = self.split.values if self.split else None 

276 filters = config.get("filters") 

277 # transform the list of dict into a dict 

278 self.filters_dict = {} 

279 if filters is not None: 

280 for item in filters: 

281 column = item["column"] 

282 self.filters_dict[column] = item["values"] 

283 logger.debug(f"[DEBUG] ParquetDataLoader.__init__: filters_dict = {self.filters_dict}") 

284 

285 self.id_column = config.get("id_column") 

286 self.sample_path = config.get("sample_path", []) 

287 self.transforms = config.get("transform", []) 

288 

289 # Storage filesystem configuration - only for S3 paths, not local paths 

290 self.filesystem = None 

291 storage_config = None 

292 storage_cfg = config.get("storage") 

293 if storage_cfg: 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was never true

294 # Use StorageConfig model to validate and access fields 

295 from dqm_ml_core.models.global_ import StorageConfig 

296 

297 storage_config = StorageConfig.model_validate(storage_cfg) 

298 

299 self.storage_config = storage_config 

300 

301 if storage_config.type == "s3": 

302 from dqm_ml_job.utils.s3 import get_s3_filesystem 

303 

304 self.filesystem = get_s3_filesystem(storage_config) 

305 

306 def _resolve_selection_path(self) -> str: 

307 """Resolve the full path, prepending S3 bucket if applicable.""" 

308 path = self.path 

309 if self.filesystem is not None and isinstance(self.filesystem, fs.S3FileSystem): 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 bucket_name = self.storage_config.bucket if self.storage_config else os.getenv("S3_BUCKET_NAME", "") 

311 if bucket_name and not path.startswith(bucket_name + "/"): 

312 path = f"{bucket_name}/{path}" 

313 return path 

314 

315 def get_selections(self) -> list[DataSelection]: 

316 """Create one or more ParquetDataSelection instances based on configuration. 

317 

318 Returns: 

319 A list of DataSelection instances. If split_by is configured, 

320 returns one selection per unique value. Otherwise, returns a 

321 single selection for the entire dataset. 

322 """ 

323 path = self._resolve_selection_path() 

324 

325 if not self.split_by: 

326 # Single selection 

327 return [ 

328 ParquetDataSelection( 

329 name=self.name, 

330 path=path, 

331 batch_size=self.batch_size, 

332 threads=self.threads, 

333 filters_dict=self.filters_dict, 

334 filesystem=self.filesystem, 

335 sample_path=self.sample_path, 

336 transforms=self.transforms, 

337 ) 

338 ] 

339 

340 # Splitting logic 

341 values = self.split_values 

342 if values is None: 

343 # Automatic discovery if split_values not provided 

344 logger.info(f"Discovering unique values for split_by='{self.split_by}' in {path}") 

345 table = pq.read_table(path, columns=[self.split_by], filesystem=self.filesystem) 

346 values = [str(v) for v in pc.unique(table.column(0)).to_pylist() if v is not None] 

347 else: 

348 # Expand wildcard patterns in values against available data 

349 if any(has_pattern(v) for v in values): 

350 logger.info(f"Expanding wildcard values for split_by='{self.split_by}' in {path}") 

351 table = pq.read_table(path, columns=[self.split_by], filesystem=self.filesystem) 

352 available = [str(v) for v in pc.unique(table.column(0)).to_pylist() if v is not None] 

353 values = resolve_patterns(values, available) 

354 

355 # Apply split.exclude (including wildcard patterns) 

356 if self.split and self.split.exclude: 

357 values = resolve_include_exclude(None, self.split.exclude, values) 

358 

359 selections: list[DataSelection] = [] 

360 for val in values: 

361 selection_name = f"{self.name}_{val}" 

362 # Merge existing filters with the split filter 

363 merged_filters = (self.filters_dict or {}).copy() 

364 # TODO: filters shouldn't be on the same column than split by 

365 # raise error here ? or add this check in pydantic model ? is it possible ? 

366 merged_filters[self.split_by] = val 

367 

368 selections.append( 

369 ParquetDataSelection( 

370 name=selection_name, 

371 path=path, 

372 batch_size=self.batch_size, 

373 threads=self.threads, 

374 filters_dict=merged_filters, 

375 filesystem=self.filesystem, 

376 sample_path=self.sample_path, 

377 transforms=self.transforms, 

378 ) 

379 ) 

380 return selections