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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- 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
|
+
)
|