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,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