firecube 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,386 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Typed result metrics and outputs for ingestion results."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from firecube.core.controlplane import SpanCoverage
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class StorageMetrics:
28
+ """Typed storage summary from plugin results."""
29
+
30
+ path: str | None = None
31
+ bytes: int = 0
32
+ files: int = 0
33
+ duration_s: float = 0.0
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Render storage metrics as a compatibility dictionary."""
37
+ return {
38
+ "path": self.path,
39
+ "bytes": self.bytes,
40
+ "files": self.files,
41
+ "duration_s": self.duration_s,
42
+ }
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class PipelineMetrics:
47
+ """Typed pipeline metrics from plugin results."""
48
+
49
+ duration_pipeline_s: float = 0.0
50
+ rows_processed: int | None = None
51
+ rows_ingested: int | None = None
52
+ coverage: list[SpanCoverage] = field(default_factory=list)
53
+ duration_upload_s: float = 0.0
54
+ duration_total_s: float = 0.0
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ """Render pipeline metrics as a compatibility dictionary."""
58
+ return {
59
+ "duration_pipeline_s": self.duration_pipeline_s,
60
+ "rows_processed": self.rows_processed,
61
+ "rows_ingested": self.rows_ingested,
62
+ "coverage": list(self.coverage),
63
+ "duration_upload_s": self.duration_upload_s,
64
+ "duration_total_s": self.duration_total_s,
65
+ }
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class ResultMetrics:
70
+ """Shared metrics shape used by both batch- and run-level results."""
71
+
72
+ write_mode: str | None = None
73
+ storage: StorageMetrics | None = None
74
+ pipeline: PipelineMetrics | None = None
75
+ storage_handled: bool = False
76
+ _compat: dict[str, Any] = field(default_factory=dict, init=False, repr=False, compare=False)
77
+
78
+ def __post_init__(self) -> None:
79
+ """Seed compatibility mapping state from typed fields when needed."""
80
+ if self._compat:
81
+ return
82
+ if self.write_mode is not None:
83
+ self._compat["write_mode"] = self.write_mode
84
+ if self.storage is not None:
85
+ self._compat["storage"] = self.storage.to_dict()
86
+ if self.pipeline is not None:
87
+ self._compat["pipeline"] = self.pipeline.to_dict()
88
+ if self.storage_handled:
89
+ self._compat["storage_handled"] = self.storage_handled
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ """Render typed fields as a public dict (no private ``_compat`` leak)."""
93
+ rendered: dict[str, Any] = {}
94
+ if self.write_mode is not None:
95
+ rendered["write_mode"] = self.write_mode
96
+ if self.storage is not None:
97
+ rendered["storage"] = self.storage.to_dict()
98
+ if self.pipeline is not None:
99
+ rendered["pipeline"] = self.pipeline.to_dict()
100
+ if self.storage_handled:
101
+ rendered["storage_handled"] = self.storage_handled
102
+ for key, value in self._compat.items():
103
+ if key not in rendered:
104
+ rendered[key] = value
105
+ continue
106
+ existing = rendered[key]
107
+ if isinstance(existing, dict) and isinstance(value, dict):
108
+ merged = dict(value)
109
+ merged.update(existing)
110
+ rendered[key] = merged
111
+ return rendered
112
+
113
+ def get(self, key: str, default: Any = None) -> Any:
114
+ """Return a compatibility-mapped metric value by key."""
115
+ compat = self._compat
116
+ if key == "write_mode":
117
+ return self.write_mode if self.write_mode is not None else default
118
+ if key == "storage_handled":
119
+ return self.storage_handled
120
+ if key == "storage":
121
+ if self.storage is None:
122
+ return compat.get(key, default)
123
+ rendered = self.storage.to_dict()
124
+ compat_storage = compat.get("storage")
125
+ if isinstance(compat_storage, dict):
126
+ for k, v in compat_storage.items():
127
+ if k not in rendered:
128
+ rendered[k] = v
129
+ compat["storage"] = rendered
130
+ return rendered
131
+ if key == "pipeline":
132
+ if self.pipeline is None:
133
+ return compat.get(key, default)
134
+ rendered = self.pipeline.to_dict()
135
+ compat_pipeline = compat.get("pipeline")
136
+ if isinstance(compat_pipeline, dict):
137
+ for k, v in compat_pipeline.items():
138
+ if k not in rendered:
139
+ rendered[k] = v
140
+ compat["pipeline"] = rendered
141
+ return rendered
142
+ if key in compat:
143
+ return compat[key]
144
+ return default
145
+
146
+ def __getitem__(self, key: str) -> Any:
147
+ """Provide mapping-style access for compatibility code paths."""
148
+ value = self.get(key, None)
149
+ if value is None and key not in {"storage_handled"}:
150
+ raise KeyError(key)
151
+ return value
152
+
153
+ def __setitem__(self, key: str, value: Any) -> None:
154
+ """Update a compatibility metric entry and keep typed fields in sync."""
155
+ if key == "write_mode":
156
+ self.write_mode = value
157
+ self._compat[key] = value
158
+ return
159
+ if key == "storage":
160
+ self.storage = _coerce_storage_metrics(value)
161
+ self._compat[key] = (
162
+ value
163
+ if isinstance(value, dict)
164
+ else self.storage.to_dict()
165
+ if self.storage
166
+ else None
167
+ )
168
+ return
169
+ if key == "pipeline":
170
+ self.pipeline = _coerce_pipeline_metrics(value)
171
+ self._compat[key] = (
172
+ value
173
+ if isinstance(value, dict)
174
+ else self.pipeline.to_dict()
175
+ if self.pipeline
176
+ else None
177
+ )
178
+ return
179
+ if key == "storage_handled":
180
+ self.storage_handled = bool(value)
181
+ self._compat[key] = self.storage_handled
182
+ return
183
+ raise KeyError(key)
184
+
185
+ def items(self):
186
+ """Return compatibility key/value pairs."""
187
+ return self._compat.items() if self._compat else ((key, self[key]) for key in self.keys())
188
+
189
+ def values(self):
190
+ """Return compatibility values."""
191
+ return self._compat.values() if self._compat else (self[key] for key in self.keys())
192
+
193
+ def update(self, other: Any = None, /, **kwargs: Any) -> None:
194
+ """Update compatibility metrics from another mapping or keyword args."""
195
+ if other is not None:
196
+ if hasattr(other, "items"):
197
+ for key, value in other.items():
198
+ self[key] = value
199
+ else:
200
+ for key, value in other:
201
+ self[key] = value
202
+ for key, value in kwargs.items():
203
+ self[key] = value
204
+
205
+ def setdefault(self, key: str, default: Any = None) -> Any:
206
+ """Set a compatibility metric only when missing."""
207
+ if key in self._compat:
208
+ return self._compat[key]
209
+ self[key] = default
210
+ return self._compat.get(key, default)
211
+
212
+ def keys(self) -> tuple[str, ...]:
213
+ """Return the current compatibility keys in insertion order."""
214
+ if self._compat:
215
+ return tuple(self._compat.keys())
216
+ keys: list[str] = []
217
+ if self.write_mode is not None:
218
+ keys.append("write_mode")
219
+ if self.storage is not None:
220
+ keys.append("storage")
221
+ if self.pipeline is not None:
222
+ keys.append("pipeline")
223
+ if self.storage_handled:
224
+ keys.append("storage_handled")
225
+ return tuple(keys)
226
+
227
+ def __iter__(self):
228
+ """Iterate over compatibility metric keys."""
229
+ return iter(self.keys())
230
+
231
+ def __len__(self) -> int:
232
+ """Return the number of populated compatibility keys."""
233
+ return len(self.keys())
234
+
235
+ def __contains__(self, key: object) -> bool:
236
+ return key in self._compat
237
+
238
+
239
+ @dataclass(slots=True)
240
+ class OutputPaths:
241
+ """Shared outputs shape used by both batch- and run-level results."""
242
+
243
+ primary: Path | str | None = None
244
+ zarr: Path | str | None = None
245
+
246
+ def get(self, key: str, default: Any = None) -> Any:
247
+ """Return an output path by compatibility key."""
248
+ if key in {"primary", "output_path", "path"}:
249
+ return self.primary if self.primary is not None else default
250
+ if key == "zarr":
251
+ if self.zarr is not None:
252
+ return self.zarr
253
+ if self.primary is not None:
254
+ return self.primary
255
+ return default
256
+ return default
257
+
258
+ def __getitem__(self, key: str) -> Any:
259
+ value = self.get(key, None)
260
+ if value is None:
261
+ raise KeyError(key)
262
+ return value
263
+
264
+ def keys(self) -> tuple[str, ...]:
265
+ """Return the populated output keys in insertion order."""
266
+ keys: list[str] = []
267
+ if self.primary is not None:
268
+ keys.append("primary")
269
+ if self.zarr is not None:
270
+ keys.append("zarr")
271
+ return tuple(keys)
272
+
273
+ def __iter__(self):
274
+ return iter(self.keys())
275
+
276
+ def __len__(self) -> int:
277
+ return len(self.keys())
278
+
279
+ def items(self):
280
+ """Return compatibility key/value pairs."""
281
+ return ((key, self[key]) for key in self.keys())
282
+
283
+
284
+ def _coerce_storage_metrics(value: Any) -> StorageMetrics | None:
285
+ if value is None or isinstance(value, StorageMetrics):
286
+ return value
287
+ if isinstance(value, dict):
288
+ return StorageMetrics(
289
+ path=value.get("path"),
290
+ bytes=int(value.get("bytes", 0) or 0),
291
+ files=int(value.get("files", 0) or 0),
292
+ duration_s=float(value.get("duration_s", 0.0) or 0.0),
293
+ )
294
+ return None
295
+
296
+
297
+ def _coerce_pipeline_metrics(value: Any) -> PipelineMetrics | None:
298
+ if value is None or isinstance(value, PipelineMetrics):
299
+ return value
300
+ if isinstance(value, dict):
301
+ return PipelineMetrics(
302
+ duration_pipeline_s=float(value.get("duration_pipeline_s", 0.0) or 0.0),
303
+ rows_processed=value.get("rows_processed"),
304
+ rows_ingested=value.get("rows_ingested"),
305
+ coverage=_coerce_span_coverage_list(value.get("coverage")),
306
+ duration_upload_s=float(value.get("duration_upload_s", 0.0) or 0.0),
307
+ duration_total_s=float(value.get("duration_total_s", 0.0) or 0.0),
308
+ )
309
+ return None
310
+
311
+
312
+ def _coerce_span_coverage_list(value: Any) -> list[SpanCoverage]:
313
+ if not value:
314
+ return []
315
+ spans: list[SpanCoverage] = []
316
+ for item in value:
317
+ if isinstance(item, SpanCoverage):
318
+ spans.append(item)
319
+ elif isinstance(item, dict) and "group" in item:
320
+ spans.append(
321
+ SpanCoverage(
322
+ group=item["group"],
323
+ arrays=list(item.get("arrays", [])),
324
+ time_index_ranges=(
325
+ list(item["time_index_ranges"]) if "time_index_ranges" in item else None
326
+ ),
327
+ aligned=bool(item.get("aligned", True)),
328
+ state_array=item.get("state_array"),
329
+ state_deleted_value=int(item.get("state_deleted_value", 2)),
330
+ time_min=item.get("time_min"),
331
+ time_max=item.get("time_max"),
332
+ region_spec=item.get("region_spec"),
333
+ write_strategy=item.get("write_strategy"),
334
+ time_dim_name=item.get("time_dim_name"),
335
+ )
336
+ )
337
+ return spans
338
+
339
+
340
+ def _coerce_result_metrics(value: Any) -> ResultMetrics:
341
+ if value is None:
342
+ return ResultMetrics()
343
+ if isinstance(value, ResultMetrics):
344
+ return value
345
+ if isinstance(value, dict):
346
+ pipeline = _coerce_pipeline_metrics(value.get("pipeline"))
347
+ if pipeline is None and value.get("coverage"):
348
+ pipeline = PipelineMetrics(coverage=_coerce_span_coverage_list(value.get("coverage")))
349
+ metrics = ResultMetrics(
350
+ write_mode=value.get("write_mode"),
351
+ storage=_coerce_storage_metrics(value.get("storage")),
352
+ pipeline=pipeline,
353
+ storage_handled=bool(value.get("storage_handled", False)),
354
+ )
355
+ metrics._compat = dict(value)
356
+ return metrics
357
+ return ResultMetrics()
358
+
359
+
360
+ def _coerce_output_paths(value: Any, *, output_format: str) -> OutputPaths:
361
+ if isinstance(value, OutputPaths):
362
+ outputs = value
363
+ elif isinstance(value, dict):
364
+ outputs = OutputPaths(
365
+ primary=value.get("primary")
366
+ or value.get("output_path")
367
+ or value.get("path")
368
+ or value.get(output_format),
369
+ zarr=value.get("zarr"),
370
+ )
371
+ else:
372
+ outputs = OutputPaths()
373
+
374
+ if outputs.zarr is None and output_format == "zarr" and outputs.primary is not None:
375
+ outputs.zarr = outputs.primary
376
+ return outputs
377
+
378
+
379
+ __all__ = [
380
+ "OutputPaths",
381
+ "PipelineMetrics",
382
+ "ResultMetrics",
383
+ "StorageMetrics",
384
+ "_coerce_output_paths",
385
+ "_coerce_result_metrics",
386
+ ]
@@ -0,0 +1,14 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
@@ -0,0 +1,235 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Reusable DuckDB helpers for Firecube ingestion plugins."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import re
21
+ import time
22
+ from collections.abc import Iterable
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ if TYPE_CHECKING:
27
+ import duckdb
28
+
29
+ log = logging.getLogger("firecube.ingestor.utils.duckdb")
30
+
31
+ _IDENT_PART_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
+
33
+
34
+ def _quote_ident(identifier: str) -> str:
35
+ """Safely quote a DuckDB identifier (table/column).
36
+
37
+ DuckDB does not support parameterizing identifiers. To prevent SQL injection,
38
+ we restrict identifiers to a conservative subset and always quote them.
39
+ """
40
+ identifier = str(identifier)
41
+ parts = [p for p in identifier.split(".") if p]
42
+ if not parts:
43
+ raise ValueError("Identifier cannot be empty")
44
+ for part in parts:
45
+ if not _IDENT_PART_RE.match(part):
46
+ raise ValueError(f"Invalid identifier: {identifier!r}")
47
+ return ".".join(f'"{p}"' for p in parts)
48
+
49
+
50
+ def _quote_literal(value: str) -> str:
51
+ """Safely quote a SQL string literal."""
52
+ return "'" + str(value).replace("'", "''") + "'"
53
+
54
+
55
+ def open_duckdb(in_memory: bool = True, db_path: Path | None = None) -> duckdb.DuckDBPyConnection:
56
+ """Create a DuckDB connection."""
57
+ import duckdb
58
+
59
+ if in_memory:
60
+ log.debug("Opening in-memory DuckDB instance")
61
+ return duckdb.connect(":memory:")
62
+ if not db_path:
63
+ raise ValueError("db_path must be provided when in_memory=False")
64
+ db_path.parent.mkdir(parents=True, exist_ok=True)
65
+ log.debug("Opening file-backed DuckDB at %s", db_path)
66
+ return duckdb.connect(str(db_path))
67
+
68
+
69
+ def ensure_table(con: duckdb.DuckDBPyConnection, table: str, schema_sql: str) -> None:
70
+ """Ensure a table exists using the provided CREATE TABLE statement."""
71
+ con.execute(schema_sql)
72
+ log.debug("Ensured table %s exists", table)
73
+
74
+
75
+ def truncate_table(con: duckdb.DuckDBPyConnection, table: str) -> None:
76
+ """Delete existing rows without dropping the table."""
77
+ con.execute(f"DELETE FROM {_quote_ident(table)}")
78
+ log.debug("Truncated table %s", table)
79
+
80
+
81
+ def deduplicate(
82
+ con: duckdb.DuckDBPyConnection,
83
+ table: str,
84
+ group_by: str,
85
+ value_columns: str | None = None,
86
+ ) -> dict[str, Any]:
87
+ """
88
+ Deduplicate rows using a DuckDB GROUP BY statement.
89
+
90
+ Args:
91
+ con: DuckDB connection
92
+ table: table name to deduplicate
93
+ group_by: columns to group by (comma-separated)
94
+ value_columns: optional SQL projection for value columns (defaults to ANY_VALUE for all)
95
+ """
96
+ table_ident = _quote_ident(table)
97
+ total_rows_row = con.sql(f"SELECT COUNT(*) FROM {table_ident}").fetchone()
98
+ total_rows = total_rows_row[0] if total_rows_row else 0
99
+ if total_rows == 0:
100
+ return {"total_rows": 0, "unique_rows": 0, "duplicate_rows": 0, "duplicate_ratio": 0.0}
101
+
102
+ group_cols = [c.strip() for c in str(group_by).split(",") if c.strip()]
103
+ if not group_cols:
104
+ raise ValueError("group_by must include at least one column")
105
+ group_expr = ", ".join(_quote_ident(c) for c in group_cols)
106
+
107
+ if value_columns:
108
+ vc = str(value_columns)
109
+ if ";" in vc or "--" in vc or "/*" in vc or "*/" in vc:
110
+ raise ValueError("value_columns contains disallowed SQL tokens")
111
+ projection = f"{group_expr}, {vc}"
112
+ else:
113
+ projection = f"{group_expr}, ANY_VALUE(*)"
114
+
115
+ con.execute(
116
+ f"""
117
+ CREATE TEMPORARY TABLE __dedup AS
118
+ SELECT {projection}
119
+ FROM {table_ident}
120
+ GROUP BY {group_expr}
121
+ """
122
+ )
123
+ unique_rows_row = con.sql("SELECT COUNT(*) FROM __dedup").fetchone()
124
+ unique_rows = unique_rows_row[0] if unique_rows_row else 0
125
+ con.execute(f"DELETE FROM {table_ident}")
126
+ table_cols = [
127
+ row[1] for row in con.execute(f"PRAGMA table_info({_quote_literal(table)})").fetchall()
128
+ ]
129
+ if not table_cols:
130
+ raise RuntimeError(f"Unable to determine columns for table {table}")
131
+ col_list = ", ".join(_quote_ident(c) for c in table_cols)
132
+ con.execute(f"INSERT INTO {table_ident} ({col_list}) SELECT {col_list} FROM __dedup")
133
+ con.execute("DROP TABLE __dedup")
134
+
135
+ duplicate_rows = total_rows - unique_rows
136
+ ratio = duplicate_rows / total_rows
137
+ log.debug("Deduplicated table %s: %s duplicates", table, duplicate_rows)
138
+ return {
139
+ "total_rows": int(total_rows),
140
+ "unique_rows": int(unique_rows),
141
+ "duplicate_rows": int(duplicate_rows),
142
+ "duplicate_ratio": float(ratio),
143
+ }
144
+
145
+
146
+ def export_parquet(con: duckdb.DuckDBPyConnection, table: str, output_path: Path) -> dict[str, Any]:
147
+ """Export a table to Parquet."""
148
+ output_path.parent.mkdir(parents=True, exist_ok=True)
149
+ start = time.time()
150
+ con.sql(f"COPY {_quote_ident(table)} TO {_quote_literal(str(output_path))} (FORMAT PARQUET)")
151
+ duration = time.time() - start
152
+ size_bytes = output_path.stat().st_size if output_path.exists() else 0
153
+ log.debug("Exported %s to Parquet (%s bytes)", table, size_bytes)
154
+ return {"format": "parquet", "path": output_path, "size_b": size_bytes, "duration_s": duration}
155
+
156
+
157
+ def apply_duckdb_settings(
158
+ con: duckdb.DuckDBPyConnection,
159
+ *,
160
+ max_temp_directory_size: str | None = None,
161
+ memory_limit: str | None = None,
162
+ threads: int | None = None,
163
+ logger: logging.Logger | None = None,
164
+ ) -> dict[str, Any]:
165
+ """Best-effort apply common DuckDB PRAGMA/SET settings.
166
+
167
+ This helper is intentionally optional: plugins that do not use DuckDB can
168
+ ignore it entirely. Failures are logged and do not raise by default.
169
+ """
170
+ logger = logger or log
171
+ applied: dict[str, Any] = {}
172
+
173
+ if max_temp_directory_size:
174
+ try:
175
+ con.execute(
176
+ f"PRAGMA max_temp_directory_size={_quote_literal(str(max_temp_directory_size))}"
177
+ )
178
+ applied["max_temp_directory_size"] = str(max_temp_directory_size)
179
+ except Exception as exc:
180
+ logger.warning(
181
+ "Failed to apply DuckDB max_temp_directory_size=%s: %s",
182
+ max_temp_directory_size,
183
+ exc,
184
+ )
185
+
186
+ if memory_limit:
187
+ try:
188
+ con.execute(f"SET memory_limit={_quote_literal(str(memory_limit))}")
189
+ applied["memory_limit"] = str(memory_limit)
190
+ except Exception as exc:
191
+ logger.warning("Failed to apply DuckDB memory_limit=%s: %s", memory_limit, exc)
192
+
193
+ if threads is not None:
194
+ try:
195
+ threads_int = int(threads)
196
+ if threads_int > 0:
197
+ con.execute(f"SET threads={threads_int}")
198
+ applied["threads"] = threads_int
199
+ except Exception as exc:
200
+ logger.warning("Failed to apply DuckDB threads=%s: %s", threads, exc)
201
+
202
+ return applied
203
+
204
+
205
+ def ensure_table_schema(
206
+ con: duckdb.DuckDBPyConnection,
207
+ *,
208
+ table: str,
209
+ create_sql: str,
210
+ expected_columns: Iterable[str] | None = None,
211
+ logger: logging.Logger | None = None,
212
+ ) -> bool:
213
+ """Ensure the table columns match the expected schema.
214
+
215
+ If `expected_columns` is provided and the existing column order differs,
216
+ the table is dropped and re-created from `create_sql`.
217
+
218
+ Returns True when a recreate happened.
219
+ """
220
+ logger = logger or log
221
+ if expected_columns is None:
222
+ return False
223
+
224
+ expected_list = [str(c) for c in expected_columns]
225
+ try:
226
+ info = con.execute(f"PRAGMA table_info({_quote_literal(table)})").fetchall()
227
+ except Exception:
228
+ info = []
229
+ existing = [row[1] for row in info] if info else []
230
+ if existing and existing != expected_list:
231
+ con.execute(f"DROP TABLE {_quote_ident(table)}")
232
+ con.execute(create_sql)
233
+ logger.warning("Recreated DuckDB table %s due to schema mismatch", table)
234
+ return True
235
+ return False
@@ -0,0 +1,55 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Validation logic for ingestor configuration contracts."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+
22
+ def validate_config_collisions(
23
+ base: dict[str, Any], new: dict[str, Any], scope: str = "Configuration"
24
+ ) -> None:
25
+ """Check for key collisions between two configuration dictionaries.
26
+
27
+ Args:
28
+ base: The existing configuration dictionary.
29
+ new: The new configuration dictionary to merge or check.
30
+ scope: A descriptive name for the scope being validated (e.g., "Engine options").
31
+
32
+ Raises:
33
+ TypeError: If a key in `new` already exists in `base`.
34
+ """
35
+ intersection = set(base.keys()) & set(new.keys())
36
+ if intersection:
37
+ keys = ", ".join(sorted(str(k) for k in intersection))
38
+ raise TypeError(f"Configuration key collision ({scope}): {keys}")
39
+
40
+
41
+ def declares_new_abstract_member(cls: type[Any]) -> bool:
42
+ """Return True when a subclass explicitly introduces an abstract hook."""
43
+ return any(getattr(member, "__isabstractmethod__", False) for member in cls.__dict__.values())
44
+
45
+
46
+ def validate_product_name_contract(cls: type[Any]) -> None:
47
+ """Require PRODUCT_NAME on concrete plugin subclasses at class definition time."""
48
+ product_name = getattr(cls, "PRODUCT_NAME", None)
49
+ if declares_new_abstract_member(cls) or (isinstance(product_name, str) and product_name):
50
+ return
51
+ raise TypeError(
52
+ f"{cls.__module__}.{cls.__name__} must declare PRODUCT_NAME: ClassVar[str] "
53
+ f"(non-empty string). This is the canonical logical name used in .firecube/ "
54
+ f"control-plane records, manifests, and telemetry. Add: PRODUCT_NAME = 'my_product_name'"
55
+ )