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,205 @@
|
|
|
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
|
+
"""Slot-index model for firecube product time axes.
|
|
16
|
+
|
|
17
|
+
The slot-index model describes how a product's time axis is partitioned into
|
|
18
|
+
discrete slots: one or more named groups, each with its own cadence and
|
|
19
|
+
rounding mode, anchored on a shared UTC epoch. The model is content-addressed
|
|
20
|
+
via a canonical-JSON SHA-256 (``identity_hash``) so independent writers can
|
|
21
|
+
verify they agree on the partitioning scheme without coordinating beforehand.
|
|
22
|
+
|
|
23
|
+
Stability rules:
|
|
24
|
+
|
|
25
|
+
* ``canonical_bytes()`` is deterministic: groups are emitted in alphabetical
|
|
26
|
+
order, JSON keys are sorted, and the separators are tight. ``time_unit=None``
|
|
27
|
+
is always serialised as ``"time_unit":null``.
|
|
28
|
+
* ``identity_hash`` is never embedded inside the hashed payload.
|
|
29
|
+
* Epoch normalisation is the explicit responsibility of
|
|
30
|
+
:func:`normalize_epoch_iso`; ``canonical_bytes()`` does NOT silently mutate
|
|
31
|
+
the stored epoch string. ``"Z"`` and ``"+00:00"`` therefore deliberately
|
|
32
|
+
produce different hashes -- callers that want them to converge must
|
|
33
|
+
normalise the epoch before constructing the model.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Literal
|
|
42
|
+
|
|
43
|
+
SLOT_INDEX_MODEL_ATTR = "firecube_slot_index_model"
|
|
44
|
+
SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR = "firecube_slot_index_model_identity_hash"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class SlotAxis:
|
|
49
|
+
"""A single time-axis partitioning rule for one group.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
cadence_s: Slot width in seconds; must be strictly positive.
|
|
53
|
+
mode: ``"exact"`` means timestamps must align exactly on a slot
|
|
54
|
+
boundary; ``"floor"`` means timestamps are floored to the
|
|
55
|
+
preceding boundary.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
cadence_s: int
|
|
59
|
+
mode: Literal["exact", "floor"]
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if self.cadence_s <= 0:
|
|
63
|
+
raise ValueError(f"cadence_s must be > 0, got {self.cadence_s!r}")
|
|
64
|
+
if self.mode not in {"exact", "floor"}:
|
|
65
|
+
raise ValueError(f"mode must be 'exact' or 'floor', got {self.mode!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class SlotIndexModel:
|
|
70
|
+
"""Content-addressable description of a product's slot-index partitioning.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
name: Human-readable identifier for the model (e.g. ``"opera_v1"``).
|
|
74
|
+
epoch: ISO-8601 UTC anchor (e.g. ``"2026-01-01T00:00:00Z"``); used as
|
|
75
|
+
the origin from which slot indices are counted.
|
|
76
|
+
groups: Per-group :class:`SlotAxis` mapping; at least one entry.
|
|
77
|
+
time_unit: Optional storage-precision hint
|
|
78
|
+
(e.g. ``"seconds"``, ``"milliseconds"``); ``None`` is permitted
|
|
79
|
+
and is serialised as JSON ``null``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
epoch: str
|
|
84
|
+
groups: dict[str, SlotAxis]
|
|
85
|
+
time_unit: str | None = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if not self.name:
|
|
89
|
+
raise ValueError("name must be non-empty")
|
|
90
|
+
if not self.epoch:
|
|
91
|
+
raise ValueError("epoch must be non-empty")
|
|
92
|
+
if not self.groups:
|
|
93
|
+
raise ValueError("groups must be non-empty")
|
|
94
|
+
for k in self.groups:
|
|
95
|
+
if not k:
|
|
96
|
+
raise ValueError("group keys must be non-empty strings")
|
|
97
|
+
|
|
98
|
+
def canonical_bytes(self) -> bytes:
|
|
99
|
+
"""Return the canonical UTF-8 JSON encoding of this model.
|
|
100
|
+
|
|
101
|
+
Determinism rules:
|
|
102
|
+
|
|
103
|
+
* ``groups`` are emitted in alphabetical key order.
|
|
104
|
+
* ``json.dumps(..., sort_keys=True, separators=(",", ":"))`` is used so
|
|
105
|
+
that whitespace and key order are stable across runs and platforms.
|
|
106
|
+
* ``time_unit=None`` is serialised as JSON ``null``.
|
|
107
|
+
* The output does NOT contain :attr:`identity_hash` -- the hash is
|
|
108
|
+
computed over the canonical bytes, not stored inside them.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
payload = {
|
|
112
|
+
"schema_version": "v1",
|
|
113
|
+
"name": self.name,
|
|
114
|
+
"epoch": self.epoch,
|
|
115
|
+
"time_unit": self.time_unit,
|
|
116
|
+
"groups": {
|
|
117
|
+
group: {"cadence_s": axis.cadence_s, "mode": axis.mode}
|
|
118
|
+
for group, axis in sorted(self.groups.items())
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
return json.dumps(
|
|
122
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
123
|
+
).encode("utf-8")
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def identity_hash(self) -> str:
|
|
127
|
+
"""SHA-256 hex digest of :meth:`canonical_bytes`.
|
|
128
|
+
|
|
129
|
+
Two :class:`SlotIndexModel` instances are considered equivalent
|
|
130
|
+
partitioning schemes iff their ``identity_hash`` values match.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def iso_to_epoch_s(iso: str) -> int:
|
|
137
|
+
"""Convert an ISO-8601 UTC timestamp to whole seconds since Unix epoch.
|
|
138
|
+
|
|
139
|
+
Only UTC inputs are accepted. The trailing ``"Z"`` or the explicit
|
|
140
|
+
``"+00:00"`` offset are both honoured; any other offset raises
|
|
141
|
+
:class:`ValueError`. Numpy is imported lazily so that ``slot_index`` stays
|
|
142
|
+
cheap to import for code paths that never touch the epoch helpers.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
import numpy as np
|
|
146
|
+
|
|
147
|
+
if not iso:
|
|
148
|
+
raise ValueError("iso must be a non-empty string")
|
|
149
|
+
|
|
150
|
+
text = iso.strip()
|
|
151
|
+
if text.endswith("Z"):
|
|
152
|
+
bare = text[:-1]
|
|
153
|
+
elif text.endswith("+00:00"):
|
|
154
|
+
bare = text[: -len("+00:00")]
|
|
155
|
+
else:
|
|
156
|
+
# Any other explicit timezone offset (including non-UTC offsets) is rejected.
|
|
157
|
+
# np.datetime64 silently accepts naive ISO strings, so we cannot rely on it
|
|
158
|
+
# to validate the offset for us.
|
|
159
|
+
for sign in ("+", "-"):
|
|
160
|
+
sign_idx = text.rfind(sign)
|
|
161
|
+
# The leading sign of the year is at index 0 (or 1 after a space), so
|
|
162
|
+
# only treat a sign that appears at position >= 10 as a TZ offset.
|
|
163
|
+
if sign_idx >= 10:
|
|
164
|
+
raise ValueError(f"iso must be UTC ('Z' or '+00:00' offset), got {iso!r}")
|
|
165
|
+
bare = text
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
when = np.datetime64(bare, "s")
|
|
169
|
+
except (ValueError, TypeError) as exc:
|
|
170
|
+
raise ValueError(f"could not parse ISO-8601 timestamp {iso!r}: {exc}") from exc
|
|
171
|
+
|
|
172
|
+
epoch = np.datetime64("1970-01-01T00:00:00", "s")
|
|
173
|
+
return int((when - epoch).astype("int64"))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def epoch_s_to_iso(seconds: int) -> str:
|
|
177
|
+
"""Convert whole seconds-since-epoch to a canonical ``"YYYY-MM-DDTHH:MM:SSZ"``."""
|
|
178
|
+
|
|
179
|
+
import numpy as np
|
|
180
|
+
|
|
181
|
+
when = np.datetime64("1970-01-01T00:00:00", "s") + np.timedelta64(int(seconds), "s")
|
|
182
|
+
# str(np.datetime64(..., 's')) renders as "YYYY-MM-DDTHH:MM:SS"; append the
|
|
183
|
+
# UTC marker so that the round-trip with iso_to_epoch_s is exact.
|
|
184
|
+
return f"{when!s}Z"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def normalize_epoch_iso(iso: str) -> str:
|
|
188
|
+
"""Round-trip an ISO-8601 UTC string through ``epoch_s`` and back to ``"...Z"``.
|
|
189
|
+
|
|
190
|
+
Equivalent to ``epoch_s_to_iso(iso_to_epoch_s(iso))``; raises
|
|
191
|
+
:class:`ValueError` for non-UTC inputs via :func:`iso_to_epoch_s`.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
return epoch_s_to_iso(iso_to_epoch_s(iso))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = [
|
|
198
|
+
"SLOT_INDEX_MODEL_ATTR",
|
|
199
|
+
"SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR",
|
|
200
|
+
"SlotAxis",
|
|
201
|
+
"SlotIndexModel",
|
|
202
|
+
"epoch_s_to_iso",
|
|
203
|
+
"iso_to_epoch_s",
|
|
204
|
+
"normalize_epoch_iso",
|
|
205
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
"""Storage primitives: URI value objects, driver configuration, sessions, transfer.
|
|
16
|
+
|
|
17
|
+
Public imports remain stable at ``firecube.core.storage``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from firecube.core.storage.binding import StorageBinding
|
|
21
|
+
from firecube.core.storage.cache_key import StorageCacheKey
|
|
22
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
23
|
+
from firecube.core.storage.results import StorageWriteResult
|
|
24
|
+
from firecube.core.storage.session import StorageSession
|
|
25
|
+
from firecube.core.storage.transfer import copy_file, session_for_uri
|
|
26
|
+
from firecube.core.storage.uri import (
|
|
27
|
+
LOCAL_PROTOCOLS,
|
|
28
|
+
REMOTE_PROTOCOLS,
|
|
29
|
+
SUPPORTED_PROTOCOLS,
|
|
30
|
+
StorageUri,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"LOCAL_PROTOCOLS",
|
|
35
|
+
"REMOTE_PROTOCOLS",
|
|
36
|
+
"SUPPORTED_PROTOCOLS",
|
|
37
|
+
"StorageBinding",
|
|
38
|
+
"StorageCacheKey",
|
|
39
|
+
"StorageDriverConfig",
|
|
40
|
+
"StorageSession",
|
|
41
|
+
"StorageUri",
|
|
42
|
+
"StorageWriteResult",
|
|
43
|
+
"copy_file",
|
|
44
|
+
"session_for_uri",
|
|
45
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from firecube.core.product.identity import ProductIdentity
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class StorageBinding:
|
|
28
|
+
"""Credentials are immutable and set once at the run boundary via `from_storage_config()`.
|
|
29
|
+
No mid-run credential rotation is supported."""
|
|
30
|
+
|
|
31
|
+
identity: ProductIdentity
|
|
32
|
+
driver: StorageDriverConfig
|
|
33
|
+
|
|
34
|
+
def cache_key(self):
|
|
35
|
+
from firecube.core.storage.cache_key import (
|
|
36
|
+
StorageCacheKey,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return StorageCacheKey.from_binding(self)
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class StorageCacheKey:
|
|
22
|
+
driver: str
|
|
23
|
+
protocol: str
|
|
24
|
+
authority: str | None
|
|
25
|
+
endpoint_url: str | None
|
|
26
|
+
region: str | None
|
|
27
|
+
credential_fingerprint: str | None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_binding(cls, binding):
|
|
31
|
+
|
|
32
|
+
creds = binding.driver.credentials
|
|
33
|
+
return cls(
|
|
34
|
+
driver=binding.driver.driver,
|
|
35
|
+
protocol=binding.identity.product_uri.protocol,
|
|
36
|
+
authority=binding.identity.product_uri.authority,
|
|
37
|
+
endpoint_url=binding.driver.endpoint_url,
|
|
38
|
+
region=binding.driver.region,
|
|
39
|
+
credential_fingerprint=creds.fingerprint() if creds is not None else None,
|
|
40
|
+
)
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from firecube.core.config import StorageConfig
|
|
22
|
+
from firecube.core.errors import StorageError
|
|
23
|
+
from firecube.core.filesystem import path_stats
|
|
24
|
+
from firecube.core.product import CompletionRoute, write_mode_policy
|
|
25
|
+
from firecube.core.product.identity import ProductIdentity
|
|
26
|
+
from firecube.core.product.target import ProductTarget
|
|
27
|
+
from firecube.core.storage.binding import StorageBinding
|
|
28
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
29
|
+
from firecube.core.storage.results import StorageWriteResult
|
|
30
|
+
from firecube.core.storage.session import StorageSession, storage_config_from_binding
|
|
31
|
+
from firecube.core.storage.uri import StorageUri
|
|
32
|
+
from firecube.core.uris import is_remote_target, local_path_from_target
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from firecube.core.storage.session import StorageSession as _StorageSession
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _output_session(ctx: Any) -> _StorageSession | None:
|
|
39
|
+
storage = ctx.storage
|
|
40
|
+
return storage.output if storage is not None else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _output_product_name(ctx: Any, default: str = "") -> str:
|
|
44
|
+
session = _output_session(ctx)
|
|
45
|
+
if session is not None:
|
|
46
|
+
return str(session.product.product_name)
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _storage_config_from_ctx(ctx: Any) -> StorageConfig | None:
|
|
51
|
+
session = _output_session(ctx)
|
|
52
|
+
if session is None:
|
|
53
|
+
return None
|
|
54
|
+
return storage_config_from_binding(session._binding)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class StorageCompleter:
|
|
58
|
+
def complete_output(self, result: Any, ctx: Any) -> StorageWriteResult:
|
|
59
|
+
session = _output_session(ctx)
|
|
60
|
+
if session is None:
|
|
61
|
+
raise StorageError("Storage completion requires ctx.storage.output.")
|
|
62
|
+
storage_config = storage_config_from_binding(session._binding)
|
|
63
|
+
|
|
64
|
+
effective_write_mode = str(result.metrics.write_mode or ctx.option("write_mode") or "")
|
|
65
|
+
product_uri = session.product.product_uri
|
|
66
|
+
if not product_uri.is_remote():
|
|
67
|
+
return self.complete_local_storage(result, ctx)
|
|
68
|
+
return self.complete_s3_storage(result, ctx, storage_config, effective_write_mode)
|
|
69
|
+
|
|
70
|
+
def complete_local_storage(self, result: Any, ctx: Any) -> StorageWriteResult:
|
|
71
|
+
session = _output_session(ctx)
|
|
72
|
+
if session is None:
|
|
73
|
+
raise StorageError("Local completion requires ctx.storage.output.")
|
|
74
|
+
|
|
75
|
+
product_uri = session.product.product_uri
|
|
76
|
+
final_target = Path(product_uri.path)
|
|
77
|
+
output_path = str(result.outputs.primary or "")
|
|
78
|
+
if "://" in output_path:
|
|
79
|
+
source_uri = StorageUri.parse(output_path)
|
|
80
|
+
if source_uri.protocol != "file":
|
|
81
|
+
raise StorageError(f"Local staged output must be file://, got {output_path}")
|
|
82
|
+
source_path = Path(source_uri.path).resolve()
|
|
83
|
+
else:
|
|
84
|
+
source_path = Path(output_path).resolve()
|
|
85
|
+
|
|
86
|
+
is_already_in_place = False
|
|
87
|
+
try:
|
|
88
|
+
source_path.relative_to(final_target.resolve())
|
|
89
|
+
is_already_in_place = True
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if is_already_in_place:
|
|
94
|
+
return StorageWriteResult(
|
|
95
|
+
path=str(source_path),
|
|
96
|
+
bytes_written=0,
|
|
97
|
+
files_written=0,
|
|
98
|
+
duration_s=0.0,
|
|
99
|
+
storage_type="local",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not source_path.exists():
|
|
103
|
+
raise StorageError(f"Local staged output not found at {source_path}")
|
|
104
|
+
|
|
105
|
+
upload_workers = int(ctx.option("upload_workers", 4))
|
|
106
|
+
return session.upload_tree(
|
|
107
|
+
StorageUri.from_local_path(source_path),
|
|
108
|
+
StorageUri.from_local_path(final_target),
|
|
109
|
+
parallel_workers=upload_workers,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def complete_s3_storage(
|
|
113
|
+
self,
|
|
114
|
+
result: Any,
|
|
115
|
+
ctx: Any,
|
|
116
|
+
storage_config: StorageConfig,
|
|
117
|
+
effective_write_mode: str,
|
|
118
|
+
) -> StorageWriteResult:
|
|
119
|
+
session = _output_session(ctx)
|
|
120
|
+
if session is None:
|
|
121
|
+
raise StorageError("Remote completion requires ctx.storage.output.")
|
|
122
|
+
final_target_uri = session.product.product_uri.to_str()
|
|
123
|
+
|
|
124
|
+
route = write_mode_policy(effective_write_mode).completion_route
|
|
125
|
+
if route is CompletionRoute.DIRECT:
|
|
126
|
+
return self.complete_s3_direct(result, storage_config, final_target_uri)
|
|
127
|
+
|
|
128
|
+
return self.complete_s3_staged(result, ctx, final_target_uri)
|
|
129
|
+
|
|
130
|
+
def complete_s3_direct(
|
|
131
|
+
self,
|
|
132
|
+
result: Any,
|
|
133
|
+
storage_config: StorageConfig,
|
|
134
|
+
final_target_uri: str,
|
|
135
|
+
) -> StorageWriteResult:
|
|
136
|
+
storage_summary = result.metrics.storage
|
|
137
|
+
if storage_summary is not None:
|
|
138
|
+
return StorageWriteResult(
|
|
139
|
+
path=str(storage_summary.path or final_target_uri),
|
|
140
|
+
bytes_written=int(storage_summary.bytes or 0),
|
|
141
|
+
files_written=int(storage_summary.files or 0),
|
|
142
|
+
duration_s=float(storage_summary.duration_s or 0.0),
|
|
143
|
+
storage_type="s3",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
stats = path_stats(final_target_uri, storage_config=storage_config)
|
|
147
|
+
return StorageWriteResult(
|
|
148
|
+
path=str(final_target_uri),
|
|
149
|
+
bytes_written=int(stats.get("bytes", 0) or 0),
|
|
150
|
+
files_written=int(stats.get("files", 0) or 0),
|
|
151
|
+
duration_s=0.0,
|
|
152
|
+
storage_type="s3",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def complete_s3_staged(
|
|
156
|
+
self,
|
|
157
|
+
result: Any,
|
|
158
|
+
ctx: Any,
|
|
159
|
+
final_target_uri: str,
|
|
160
|
+
) -> StorageWriteResult:
|
|
161
|
+
source_path = Path(str(result.outputs.primary or ""))
|
|
162
|
+
if result.output_format == "zarr":
|
|
163
|
+
zarr_out = result.outputs.zarr
|
|
164
|
+
zarr_out_str = str(zarr_out) if zarr_out is not None else ""
|
|
165
|
+
if zarr_out_str and not is_remote_target(zarr_out_str):
|
|
166
|
+
candidate = Path(zarr_out_str)
|
|
167
|
+
if candidate.exists():
|
|
168
|
+
source_path = candidate
|
|
169
|
+
elif zarr_out_str and zarr_out_str.startswith("file://"):
|
|
170
|
+
try:
|
|
171
|
+
candidate = local_path_from_target(zarr_out_str)
|
|
172
|
+
if candidate.exists():
|
|
173
|
+
source_path = candidate
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
resolved_source = source_path.resolve()
|
|
178
|
+
if resolved_source == Path.home().resolve() or resolved_source == Path("/").resolve():
|
|
179
|
+
raise StorageError(f"Critical Safety: Attempted to upload {resolved_source}. Aborting.")
|
|
180
|
+
|
|
181
|
+
if not source_path.exists():
|
|
182
|
+
raise StorageError(
|
|
183
|
+
f"Ingestion failed: write_mode is 'staged' but no local output "
|
|
184
|
+
f"was found at {source_path}. "
|
|
185
|
+
"If the plugin writes directly to the target, use --write-mode direct."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
storage_config = _storage_config_from_ctx(ctx)
|
|
189
|
+
if storage_config is None:
|
|
190
|
+
raise StorageError("Staged remote upload requires ctx.storage.output.")
|
|
191
|
+
|
|
192
|
+
driver_config = StorageDriverConfig.from_storage_config(storage_config)
|
|
193
|
+
storage_session = _output_session(ctx)
|
|
194
|
+
session = storage_session
|
|
195
|
+
if session is None:
|
|
196
|
+
resolved_product = ProductTarget.resolve(
|
|
197
|
+
final_target_uri,
|
|
198
|
+
driver_config,
|
|
199
|
+
product_name=_output_product_name(ctx, final_target_uri),
|
|
200
|
+
plugin_default_format=result.output_format or "zarr",
|
|
201
|
+
)
|
|
202
|
+
session = StorageSession(
|
|
203
|
+
StorageBinding(
|
|
204
|
+
identity=ProductIdentity(
|
|
205
|
+
product_name=resolved_product.product_name,
|
|
206
|
+
product_uri=resolved_product.product_uri,
|
|
207
|
+
control_root_uri=resolved_product.control_root_uri,
|
|
208
|
+
format=resolved_product.format,
|
|
209
|
+
),
|
|
210
|
+
driver=driver_config,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
effective_target_uri = (
|
|
215
|
+
session.product.product_uri.to_str()
|
|
216
|
+
if storage_session is not None
|
|
217
|
+
else final_target_uri
|
|
218
|
+
)
|
|
219
|
+
telemetry = getattr(ctx, "telemetry", None)
|
|
220
|
+
upload_span = (
|
|
221
|
+
telemetry.span(
|
|
222
|
+
"firecube.upload_s3",
|
|
223
|
+
attributes={"firecube.target": str(final_target_uri)},
|
|
224
|
+
)
|
|
225
|
+
if telemetry is not None
|
|
226
|
+
else contextlib.nullcontext()
|
|
227
|
+
)
|
|
228
|
+
upload_workers = int(ctx.option("upload_workers", 4))
|
|
229
|
+
source_uri = StorageUri.from_local_path(source_path)
|
|
230
|
+
with upload_span:
|
|
231
|
+
stored = session.upload_tree(
|
|
232
|
+
source_uri,
|
|
233
|
+
StorageUri.parse(effective_target_uri),
|
|
234
|
+
parallel_workers=upload_workers,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
result.metrics.storage_handled = True
|
|
238
|
+
pipeline_metrics = result.metrics.pipeline
|
|
239
|
+
if pipeline_metrics is not None:
|
|
240
|
+
pipeline_metrics.duration_upload_s = stored.duration_s
|
|
241
|
+
pipeline_metrics.duration_total_s = (
|
|
242
|
+
pipeline_metrics.duration_pipeline_s + stored.duration_s
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return stored
|