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