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,303 @@
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
+ """Core Tensogram (.tgm) -> Zarr restore."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import shutil
22
+ import tempfile
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ import xarray as xr
28
+
29
+ from firecube.core.config import StorageConfig
30
+ from firecube.core.product.identity import ProductIdentity
31
+ from firecube.core.storage.binding import StorageBinding
32
+ from firecube.core.storage.driver_config import StorageDriverConfig
33
+ from firecube.core.storage.session import StorageSession
34
+ from firecube.core.uris import is_remote_target, local_path_from_target, parse_target
35
+
36
+ from ._compat import require_tensogram
37
+ from .controlplane_codec import deserialize_controlplane, restore_controlplane
38
+ from .schema import ARCHIVE_VERSION, KEY_ARCHIVE_VERSION, ROLE_CONTROLPLANE, ROLE_DATA
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def _make_archive_session(target: str, storage_config: Any | None) -> StorageSession:
44
+ target_uri = parse_target(target)
45
+ if storage_config is None:
46
+ target_path = Path(target_uri.path).resolve()
47
+ storage_config = StorageConfig(storage_type="local")
48
+ storage_config.target_path = str(target_path.parent) # type: ignore[attr-defined]
49
+
50
+ driver_config = StorageDriverConfig.from_storage_config(storage_config)
51
+ return StorageSession(
52
+ StorageBinding(
53
+ identity=ProductIdentity.from_uri(target_uri, "zarr", product_name=target),
54
+ driver=driver_config,
55
+ )
56
+ )
57
+
58
+
59
+ def _prepare_target(target: str, *, overwrite: bool) -> None:
60
+ if is_remote_target(target):
61
+ return
62
+
63
+ target_uri = parse_target(target)
64
+ local_target_path = Path(target_uri.path).resolve()
65
+ if not local_target_path.exists():
66
+ return
67
+
68
+ # A directory that contains ONLY .firecube/ (control-plane state from claim
69
+ # acquisition) is not a Zarr store yet — do not treat it as an existing store.
70
+ # Only raise if the directory contains actual Zarr metadata (zarr.json or .zarray).
71
+ if local_target_path.is_dir():
72
+ has_zarr_metadata = (local_target_path / "zarr.json").exists() or (
73
+ local_target_path / ".zarray"
74
+ ).exists()
75
+ if not has_zarr_metadata:
76
+ return
77
+
78
+ if not overwrite:
79
+ raise FileExistsError(
80
+ f"Target Zarr already exists: {target}. Use overwrite=True to replace it."
81
+ )
82
+
83
+ if local_target_path.is_dir():
84
+ shutil.rmtree(local_target_path)
85
+ else:
86
+ local_target_path.unlink()
87
+
88
+
89
+ def _reconstruct_compressor(config: dict[str, Any] | None) -> Any | None:
90
+ """Reconstruct a numcodecs compressor from a config dict."""
91
+ if config is None:
92
+ return None
93
+
94
+ try:
95
+ import numcodecs
96
+
97
+ return numcodecs.get_codec(config)
98
+ except Exception:
99
+ logger.debug("Could not reconstruct compressor from config: %s", config, exc_info=True)
100
+ return None
101
+
102
+
103
+ def _build_encoding(base_entries: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
104
+ encoding: dict[str, dict[str, Any]] = {}
105
+ for base_entry in base_entries:
106
+ var_name = base_entry.get("name")
107
+ if not var_name:
108
+ continue
109
+
110
+ variable_encoding: dict[str, Any] = {}
111
+ zarr_chunks = base_entry.get("zarr_chunks")
112
+ if zarr_chunks:
113
+ variable_encoding["chunks"] = tuple(zarr_chunks)
114
+
115
+ if "zarr_compressor" in base_entry:
116
+ variable_encoding["compressor"] = _reconstruct_compressor(
117
+ cast(dict[str, Any] | None, base_entry.get("zarr_compressor"))
118
+ )
119
+
120
+ if "zarr_fill_value" in base_entry:
121
+ variable_encoding["fill_value"] = base_entry.get("zarr_fill_value")
122
+
123
+ if variable_encoding:
124
+ encoding[str(var_name)] = variable_encoding
125
+
126
+ return encoding
127
+
128
+
129
+ def _open_message_dataset(
130
+ tensogram_file: Any,
131
+ message_index: int,
132
+ *,
133
+ coord_names: list[str],
134
+ ) -> tuple[xr.Dataset, str]:
135
+ raw_message = tensogram_file.read_message(message_index)
136
+ with tempfile.NamedTemporaryFile(suffix=".tgm", delete=False) as tmp:
137
+ tmp.write(raw_message)
138
+ tmp_path = tmp.name
139
+
140
+ ds = xr.open_dataset(tmp_path, engine="tensogram")
141
+ vars_to_promote = [coord_name for coord_name in coord_names if coord_name in ds.data_vars]
142
+ if vars_to_promote:
143
+ ds = ds.set_coords(vars_to_promote)
144
+ return ds, tmp_path
145
+
146
+
147
+ def _restore_data_message(
148
+ tensogram_file: Any,
149
+ message_index: int,
150
+ *,
151
+ target: str,
152
+ group_name: str | None,
153
+ session: StorageSession,
154
+ first_group: bool,
155
+ ) -> dict[str, Any]:
156
+ msg_meta = tensogram_file.file_decode_metadata(message_index)
157
+ base_entries = list(msg_meta.base or [])
158
+ extra = (msg_meta.extra or {}) if hasattr(msg_meta, "extra") else {}
159
+ firecube_meta = extra.get("firecube", {})
160
+ coord_names = list(cast(list[str], firecube_meta.get("coordinates", [])))
161
+ encoding = _build_encoding(base_entries)
162
+
163
+ ds, tmp_path = _open_message_dataset(
164
+ tensogram_file,
165
+ message_index,
166
+ coord_names=coord_names,
167
+ )
168
+ try:
169
+ mode = "w" if first_group else "a"
170
+
171
+ valid_names = set(ds.data_vars) | set(ds.coords)
172
+ encoding = {k: v for k, v in encoding.items() if k in valid_names}
173
+ session.zarr.write_dataset(
174
+ ds,
175
+ parse_target(target),
176
+ mode=mode,
177
+ group=group_name,
178
+ encoding=encoding,
179
+ )
180
+ return {
181
+ "group": group_name,
182
+ "variables": list(ds.data_vars),
183
+ "coordinates": list(ds.coords),
184
+ }
185
+ finally:
186
+ ds.close()
187
+ os.unlink(tmp_path)
188
+
189
+
190
+ def tgm_to_zarr(
191
+ source: str,
192
+ target: str,
193
+ *,
194
+ overwrite: bool = False,
195
+ storage_config: Any | None = None,
196
+ session: StorageSession | None = None,
197
+ on_group_restored: Callable[[str | None], None] | None = None,
198
+ ) -> dict[str, Any]:
199
+ """Restore a Tensogram .tgm archive to a Zarr store.
200
+
201
+ ``source`` and ``target`` accept either a bare absolute path or a
202
+ canonical URI (e.g. ``file:///…``, ``s3://…``).
203
+
204
+ Args:
205
+ source: Path to .tgm file (local only for now).
206
+ target: Output Zarr store path (local or s3://).
207
+ overwrite: If True, overwrite an existing Zarr store.
208
+ storage_config: Optional storage config used to derive remote
209
+ ``storage_options``.
210
+
211
+ Returns:
212
+ Summary dict describing the restore outcome.
213
+
214
+ Raises:
215
+ ImportError: If tensogram extras are not installed.
216
+ FileExistsError: If target exists and overwrite is False.
217
+ """
218
+ require_tensogram("tgm_to_zarr")
219
+ import tensogram as _tensogram # pyright: ignore[reportMissingImports]
220
+
221
+ tensogram = cast(Any, _tensogram)
222
+ _prepare_target(target, overwrite=overwrite)
223
+ session = session or _make_archive_session(target, storage_config)
224
+
225
+ source_path = str(local_path_from_target(source))
226
+
227
+ with tensogram.TensogramFile.open(source_path) as tensogram_file: # pyright: ignore[reportAttributeAccessIssue]
228
+ count = tensogram_file.message_count()
229
+ if count == 0:
230
+ return {
231
+ "source": source,
232
+ "target": target,
233
+ "group": None,
234
+ "groups": [],
235
+ "variables": [],
236
+ "coordinates": [],
237
+ "restored": 0,
238
+ "messages_restored": 0,
239
+ "controlplane_restored": False,
240
+ }
241
+
242
+ first_meta = tensogram_file.file_decode_metadata(0)
243
+ first_extra = (first_meta.extra or {}) if hasattr(first_meta, "extra") else {}
244
+ firecube_meta = first_extra.get("firecube", {})
245
+ archive_version = firecube_meta.get(KEY_ARCHIVE_VERSION)
246
+ if archive_version != ARCHIVE_VERSION:
247
+ raise ValueError(
248
+ f"Unsupported Firecube archive version: {archive_version!r}. "
249
+ f"Expected {ARCHIVE_VERSION!r}."
250
+ )
251
+
252
+ restored_groups: list[str] = []
253
+ restored_variables: list[str] = []
254
+ restored_coordinates: list[str] = []
255
+ controlplane_restored = False
256
+ first_group = True
257
+
258
+ for i in range(count):
259
+ msg_meta = tensogram_file.file_decode_metadata(i)
260
+ extra = (msg_meta.extra or {}) if hasattr(msg_meta, "extra") else {}
261
+ firecube_meta = extra.get("firecube", {})
262
+ role = firecube_meta.get("role", ROLE_DATA)
263
+
264
+ if role == ROLE_CONTROLPLANE:
265
+ msg = tensogram_file.decode_message(i)
266
+ if not msg.objects:
267
+ continue
268
+ cp_array = msg.objects[0][1]
269
+ cp_state = deserialize_controlplane(cp_array)
270
+ restore_controlplane(cp_state, target, storage_config=storage_config)
271
+ controlplane_restored = True
272
+ continue
273
+
274
+ group_name = cast(str | None, firecube_meta.get("group") or None)
275
+ restored = _restore_data_message(
276
+ tensogram_file,
277
+ i,
278
+ target=target,
279
+ group_name=group_name,
280
+ session=session,
281
+ first_group=first_group,
282
+ )
283
+ first_group = False
284
+
285
+ if restored["group"] is not None:
286
+ restored_groups.append(cast(str, restored["group"]))
287
+ restored_variables.extend(cast(list[str], restored["variables"]))
288
+ restored_coordinates.extend(cast(list[str], restored["coordinates"]))
289
+
290
+ if on_group_restored is not None:
291
+ on_group_restored(cast(str | None, restored["group"]))
292
+
293
+ return {
294
+ "source": source,
295
+ "target": target,
296
+ "group": None,
297
+ "groups": restored_groups,
298
+ "variables": sorted(set(restored_variables)),
299
+ "coordinates": sorted(set(restored_coordinates)),
300
+ "restored": len(restored_groups),
301
+ "messages_restored": len(restored_groups),
302
+ "controlplane_restored": controlplane_restored,
303
+ }
@@ -0,0 +1,93 @@
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
+ """Archive schema constants and metadata helpers for Firecube .tgm archives."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from datetime import UTC, datetime
20
+ from typing import Any
21
+
22
+ import firecube
23
+
24
+ # Archive format version.
25
+ ARCHIVE_VERSION: str = "v1"
26
+
27
+ # Message role constants (stored in firecube.role within message metadata)
28
+ ROLE_DATA: str = "data"
29
+ ROLE_CONTROLPLANE: str = "controlplane"
30
+
31
+ # Metadata key names (for documentation / avoiding magic strings)
32
+ KEY_GROUP: str = "group"
33
+ KEY_ROLE: str = "role"
34
+ KEY_ARCHIVE_VERSION: str = "archive_version"
35
+
36
+
37
+ def make_data_meta(
38
+ group: str,
39
+ base: list[dict[str, Any]],
40
+ *,
41
+ source_uri: str,
42
+ compression: str,
43
+ coordinates: list[str] | None = None,
44
+ start_date: str | None = None,
45
+ end_date: str | None = None,
46
+ extra_attrs: dict[str, Any] | None = None,
47
+ ) -> dict[str, Any]:
48
+ """Build a complete metadata dict for a data message.
49
+
50
+ Returns a dict suitable for passing to TensogramFile.append().
51
+ """
52
+ # Tensogram >=0.18 metadata frames are free-form; the wire version lives in
53
+ # the file preamble, not here. The legacy top-level "version" key (the
54
+ # removed GlobalMetadata.version field from tensogram <0.18) is not written.
55
+ meta: dict[str, Any] = {
56
+ "base": base,
57
+ "firecube": {
58
+ "version": getattr(firecube, "__version__", "unknown"),
59
+ "archive_version": ARCHIVE_VERSION,
60
+ "role": ROLE_DATA,
61
+ "group": group,
62
+ "source_uri": source_uri,
63
+ "archived_at": datetime.now(UTC).isoformat(),
64
+ "compression": compression,
65
+ },
66
+ }
67
+ if coordinates is not None:
68
+ meta["firecube"]["coordinates"] = coordinates
69
+ if start_date is not None:
70
+ meta["firecube"]["start_date"] = start_date
71
+ if end_date is not None:
72
+ meta["firecube"]["end_date"] = end_date
73
+ if extra_attrs:
74
+ meta.update(extra_attrs)
75
+ return meta
76
+
77
+
78
+ def make_controlplane_meta(product: str) -> dict[str, Any]:
79
+ """Build a metadata dict for a control-plane message.
80
+
81
+ Returns a dict suitable for passing to TensogramFile.append().
82
+ The control-plane message has no group — it covers all groups for the product.
83
+ """
84
+ return {
85
+ "base": [{"name": "controlplane", "dim_names": ["bytes"]}],
86
+ "firecube": {
87
+ "version": getattr(firecube, "__version__", "unknown"),
88
+ "archive_version": ARCHIVE_VERSION,
89
+ "role": ROLE_CONTROLPLANE,
90
+ "product": product,
91
+ "archived_at": datetime.now(UTC).isoformat(),
92
+ },
93
+ }
firecube/core/uris.py ADDED
@@ -0,0 +1,182 @@
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
+ """URI/path helpers used across core, CLI, and plugins.
16
+
17
+ This module centralizes protocol detection and local path normalization so we
18
+ don't duplicate `fsspec.utils.infer_storage_options(...)` logic in multiple
19
+ places (storage, filesystem, CLI).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ import re
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ from fsspec.utils import infer_storage_options
31
+
32
+ if TYPE_CHECKING:
33
+ from firecube.core.storage.uri import StorageUri
34
+
35
+ _log = logging.getLogger(__name__)
36
+ _URI_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+\-.]*[:]{0,1}[/]{1,2}")
37
+ _WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^[a-zA-Z]:[/\\]")
38
+ _MALFORMED_URI_SCHEMELESS_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*)//")
39
+ _COMMON_URI_SCHEMES = {
40
+ "abfs",
41
+ "adl",
42
+ "az",
43
+ "file",
44
+ "ftp",
45
+ "gcs",
46
+ "gs",
47
+ "hdfs",
48
+ "http",
49
+ "https",
50
+ "memory",
51
+ "s3",
52
+ "sftp",
53
+ }
54
+
55
+
56
+ def _looks_like_uri(target: str) -> bool:
57
+ """Return True if target appears to be an intentional URI scheme (even if malformed)."""
58
+ return bool(_URI_SCHEME_RE.match(target))
59
+
60
+
61
+ def _looks_like_windows_absolute_path(target: str) -> bool:
62
+ """Return True for Windows drive-letter absolute paths on any host OS."""
63
+ return bool(_WINDOWS_ABSOLUTE_PATH_RE.match(target))
64
+
65
+
66
+ def _is_malformed_uri_candidate(target: str, protocol: str, path: str) -> bool:
67
+ """Return True when a URI-like target resolved only as a plain local path."""
68
+ if protocol != "file" or path != target or _looks_like_windows_absolute_path(target):
69
+ return False
70
+
71
+ if target.startswith("://") or "://" in target:
72
+ return True
73
+
74
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+\-.]*:/", target):
75
+ return True
76
+
77
+ schemeless_match = _MALFORMED_URI_SCHEMELESS_RE.match(target)
78
+ return bool(schemeless_match and schemeless_match.group(1).lower() in _COMMON_URI_SCHEMES)
79
+
80
+
81
+ def parse_uri(target: str) -> dict[str, str]:
82
+ """Parse a URI/path into a minimal fsspec-like structure.
83
+
84
+ Returns a dict with:
85
+ - protocol: "file", "s3", ...
86
+ - path: protocol-specific path (e.g. "bucket/prefix/key" for s3, "/abs/path" for file)
87
+ """
88
+ target = str(target or "")
89
+ if target.startswith("file://"):
90
+ # Treat file:// as local. fsspec parsing will give us an absolute path if possible.
91
+ try:
92
+ parsed = infer_storage_options(target)
93
+ except Exception:
94
+ if _looks_like_uri(target):
95
+ raise ValueError(f"Malformed URI: {target!r}") from None
96
+ return {"protocol": "file", "path": target.replace("file://", "", 1)}
97
+ else:
98
+ try:
99
+ parsed = infer_storage_options(target)
100
+ except Exception:
101
+ if _looks_like_uri(target):
102
+ raise ValueError(f"Malformed URI: {target!r}") from None
103
+ return {"protocol": "file", "path": target}
104
+
105
+ protocol = parsed.get("protocol") or "file"
106
+ if isinstance(protocol, (list, tuple)):
107
+ protocol = protocol[0] if protocol else "file"
108
+ path = str(parsed.get("path") or "")
109
+ if _is_malformed_uri_candidate(target, str(protocol), path):
110
+ raise ValueError(f"Malformed URI: {target!r}")
111
+ _log.debug("parse_uri(%r) -> protocol=%s path=%s", target, protocol, path)
112
+ return {"protocol": str(protocol), "path": path}
113
+
114
+
115
+ def infer_target_protocol(target: str) -> str:
116
+ """Infer the fsspec protocol for a target string.
117
+
118
+ Returns "file" for local paths and file:// URIs.
119
+ """
120
+ return parse_uri(target)["protocol"]
121
+
122
+
123
+ def is_remote_target(target: str) -> bool:
124
+ """Return True when `target` is a non-local URI (e.g. s3://...)."""
125
+ protocol = infer_target_protocol(target)
126
+ return bool(protocol and protocol != "file")
127
+
128
+
129
+ def local_path_from_target(target: str) -> Path:
130
+ """Resolve a target string into an absolute local path.
131
+
132
+ Supports relative paths and file:// URIs.
133
+ """
134
+ target = str(target or "")
135
+ if target.startswith("file://"):
136
+ from firecube.core.storage.uri import StorageUri
137
+
138
+ uri = StorageUri.parse(target)
139
+ return Path(uri.path)
140
+ p = Path(target) # firecube: STORAGE-URI — intentional: this IS the local-path adapter
141
+ return p if p.is_absolute() else (Path.cwd() / p)
142
+
143
+
144
+ def storage_uri_from_target(target: str) -> StorageUri:
145
+ from firecube.core.storage.uri import StorageUri # local import to avoid circular
146
+
147
+ if is_remote_target(target) or str(target).startswith("file://"):
148
+ return StorageUri.parse(target)
149
+ return StorageUri.from_local_path(local_path_from_target(target))
150
+
151
+
152
+ def is_windows_absolute_path(target: str) -> bool:
153
+ """Best-effort check for Windows drive-letter absolute paths."""
154
+ target = str(target or "")
155
+ return bool(os.name == "nt" and ":" in target)
156
+
157
+
158
+ def parse_target(target: StorageUri | str) -> StorageUri:
159
+ """Parse a target string tolerant of bare local paths.
160
+
161
+ Accepts:
162
+ - StorageUri: returned as-is
163
+ - file:///… URI: parsed strictly via StorageUri.parse
164
+ - s3:// gs:// memory:// URIs: parsed strictly via StorageUri.parse
165
+ - Bare absolute local path (/abs/path): coerced via StorageUri.from_local_path
166
+ - Bare relative path: rejected (use file:/// or absolute path)
167
+
168
+ This is the tolerant boundary helper for public APIs that accept
169
+ user-supplied target strings (CLI args, plugin entry points,
170
+ legacy-shaped function signatures). Internal code should use
171
+ StorageUri.parse directly (strict).
172
+ """
173
+ from firecube.core.storage.uri import StorageUri # local import to avoid circular
174
+
175
+ if isinstance(target, StorageUri):
176
+ return target
177
+ s = str(target)
178
+ if "://" in s:
179
+ return StorageUri.parse(s)
180
+ if s.startswith("/"):
181
+ return StorageUri.from_local_path(s)
182
+ raise ValueError(f"target must be an absolute path or URI: {target!r}")
@@ -0,0 +1,68 @@
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
+ """Generic runtime helpers for ingestion plugins.
16
+
17
+ These helpers are intentionally optional. Plugins that need a workspace and/or
18
+ temporary directory root can use them to avoid duplicating path wiring logic.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import tempfile
24
+ from collections.abc import Mapping
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ def resolve_workspace_root(
30
+ options: Mapping[str, Any],
31
+ *,
32
+ prefix: str,
33
+ option_key: str = "workspace",
34
+ ) -> tuple[Path, bool]:
35
+ """Return (workspace_root, autocreated).
36
+
37
+ If `options[option_key]` is present, the workspace is treated as
38
+ user-provided (autocreated=False). Otherwise, a temporary directory is
39
+ created with the given prefix.
40
+ """
41
+ user_value = options.get(option_key)
42
+ if user_value:
43
+ return Path(user_value), False
44
+ return Path(tempfile.mkdtemp(prefix=f"{prefix}_")), True
45
+
46
+
47
+ def ensure_workspace_dirs(
48
+ workspace_root: Path,
49
+ *,
50
+ temp_subdir: str = "tmp",
51
+ ) -> tuple[Path, Path]:
52
+ """Ensure workspace directory exists and return (workspace_root, temp_root)."""
53
+ workspace_root.mkdir(parents=True, exist_ok=True)
54
+ temp_root = workspace_root / temp_subdir
55
+ temp_root.mkdir(parents=True, exist_ok=True)
56
+ return workspace_root, temp_root
57
+
58
+
59
+ def ensure_batch_workspace(
60
+ workspace_root: Path,
61
+ *,
62
+ batch_id: str,
63
+ prefix: str = "pipeline",
64
+ temp_subdir: str = "tmp",
65
+ ) -> tuple[Path, Path]:
66
+ """Return (batch_workspace, temp_root) as a subdirectory under workspace_root."""
67
+ batch_workspace = workspace_root / f"{prefix}_{batch_id}"
68
+ return ensure_workspace_dirs(batch_workspace, temp_subdir=temp_subdir)