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,142 @@
|
|
|
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
|
+
"""Cross-endpoint file transfer primitives.
|
|
16
|
+
|
|
17
|
+
StorageSession owns one endpoint binding. Transfers between two endpoints
|
|
18
|
+
(e.g., local filesystem → remote S3) require explicit per-endpoint
|
|
19
|
+
sessions. This module provides stateless transfer primitives that open
|
|
20
|
+
each endpoint with its own driver-correct filesystem.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shutil
|
|
26
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
27
|
+
|
|
28
|
+
from firecube.core.storage.uri import StorageUri
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
32
|
+
from firecube.core.storage.session import StorageSession
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def session_for_uri(
|
|
36
|
+
uri: StorageUri,
|
|
37
|
+
driver_config: StorageDriverConfig,
|
|
38
|
+
*,
|
|
39
|
+
format: str = "tensogram",
|
|
40
|
+
) -> StorageSession:
|
|
41
|
+
"""Construct a minimal StorageSession bound to ``uri``'s endpoint.
|
|
42
|
+
|
|
43
|
+
Used by callers (e.g., CLI archive create/restore) that need to
|
|
44
|
+
transfer to/from a URI that is NOT the same endpoint as their
|
|
45
|
+
primary product session. The returned session is single-purpose:
|
|
46
|
+
its product_uri is the input ``uri`` itself, and credentials/region
|
|
47
|
+
come from ``driver_config``.
|
|
48
|
+
|
|
49
|
+
For obstore (which is bucket-scoped), this guarantees the right
|
|
50
|
+
S3Store is constructed. For fsspec, it ensures consistent driver
|
|
51
|
+
configuration even when bucket differs from the product session.
|
|
52
|
+
|
|
53
|
+
``format`` defaults to ``"tensogram"`` (the primary archive transfer
|
|
54
|
+
artifact). Caller may override with any value in
|
|
55
|
+
``ProductIdentity.VALID_FORMATS`` if the transfer target is a
|
|
56
|
+
different format. Format is metadata-only and does not affect
|
|
57
|
+
transfer behavior; it satisfies ``ProductIdentity`` validation.
|
|
58
|
+
"""
|
|
59
|
+
from firecube.core.product.identity import ProductIdentity
|
|
60
|
+
from firecube.core.storage.binding import StorageBinding
|
|
61
|
+
from firecube.core.storage.session import StorageSession
|
|
62
|
+
|
|
63
|
+
identity = ProductIdentity(
|
|
64
|
+
product_uri=uri,
|
|
65
|
+
control_root_uri=uri.parent().join(".firecube"),
|
|
66
|
+
product_name=uri.path.rstrip("/").rsplit("/", 1)[-1] or "transfer",
|
|
67
|
+
format=format,
|
|
68
|
+
)
|
|
69
|
+
return StorageSession(StorageBinding(identity=identity, driver=driver_config))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def copy_file(
|
|
73
|
+
src: StorageUri,
|
|
74
|
+
dst: StorageUri,
|
|
75
|
+
*,
|
|
76
|
+
source_session: StorageSession | None = None,
|
|
77
|
+
target_session: StorageSession | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Copy a single file between two storage endpoints.
|
|
80
|
+
|
|
81
|
+
Strict semantics:
|
|
82
|
+
- If src is remote: source_session is REQUIRED and must be bound to src's endpoint
|
|
83
|
+
- If src is local: source_session is ignored; opened via local Path
|
|
84
|
+
- If dst is remote: target_session is REQUIRED and must be bound to dst's endpoint
|
|
85
|
+
- If dst is local: target_session is ignored; opened via local Path
|
|
86
|
+
|
|
87
|
+
Raises ValueError if a remote endpoint lacks its required session.
|
|
88
|
+
Raises ValueError if a provided session's binding doesn't match the URI's endpoint.
|
|
89
|
+
|
|
90
|
+
Validation order: ALL session/binding checks complete BEFORE any file
|
|
91
|
+
handles are opened. This guarantees mismatched-binding failures are
|
|
92
|
+
surfaced loudly without leaking file handles or partial writes.
|
|
93
|
+
"""
|
|
94
|
+
from firecube.core.uris import local_path_from_target
|
|
95
|
+
|
|
96
|
+
# PHASE 1: validate sessions+bindings BEFORE any I/O
|
|
97
|
+
if src.is_remote():
|
|
98
|
+
if source_session is None:
|
|
99
|
+
raise ValueError(f"remote source {src.to_str()} requires source_session")
|
|
100
|
+
_validate_session_binding(source_session, src, role="source")
|
|
101
|
+
if dst.is_remote():
|
|
102
|
+
if target_session is None:
|
|
103
|
+
raise ValueError(f"remote destination {dst.to_str()} requires target_session")
|
|
104
|
+
_validate_session_binding(target_session, dst, role="target")
|
|
105
|
+
|
|
106
|
+
# PHASE 2: open both handles (validation already passed)
|
|
107
|
+
if src.is_remote():
|
|
108
|
+
assert source_session is not None # narrowed by Phase 1
|
|
109
|
+
src_handle = cast(Any, source_session.fs()).open(src, "rb")
|
|
110
|
+
else:
|
|
111
|
+
src_path = local_path_from_target(src.to_str())
|
|
112
|
+
src_handle = src_path.open("rb")
|
|
113
|
+
|
|
114
|
+
if dst.is_remote():
|
|
115
|
+
assert target_session is not None # narrowed by Phase 1
|
|
116
|
+
dst_handle = cast(Any, target_session.fs()).open(dst, "wb")
|
|
117
|
+
else:
|
|
118
|
+
dst_path = local_path_from_target(dst.to_str())
|
|
119
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
dst_handle = dst_path.open("wb")
|
|
121
|
+
|
|
122
|
+
# PHASE 3: copy
|
|
123
|
+
try:
|
|
124
|
+
shutil.copyfileobj(src_handle, dst_handle)
|
|
125
|
+
finally:
|
|
126
|
+
src_handle.close()
|
|
127
|
+
dst_handle.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate_session_binding(
|
|
131
|
+
session: StorageSession,
|
|
132
|
+
uri: StorageUri,
|
|
133
|
+
*,
|
|
134
|
+
role: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Ensure session is bound to the same protocol+authority as the URI."""
|
|
137
|
+
bound_uri = session.product.product_uri
|
|
138
|
+
if bound_uri.protocol != uri.protocol or bound_uri.authority != uri.authority:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"{role}_session is bound to {bound_uri.protocol}://{bound_uri.authority}, "
|
|
141
|
+
f"but URI is {uri.protocol}://{uri.authority}"
|
|
142
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
"""Canonical storage URI value object.
|
|
16
|
+
|
|
17
|
+
Canonical URI Contract:
|
|
18
|
+
- Protocol is always lowercased. Supported protocols are: s3, gs, file, memory.
|
|
19
|
+
- Authority is required for s3/gs (bucket) and absent for file/memory.
|
|
20
|
+
- file://localhost/... normalizes localhost to absent authority.
|
|
21
|
+
- Path always starts with /, collapses duplicate slashes, and strips trailing /
|
|
22
|
+
except for root (/).
|
|
23
|
+
- Query strings and fragments are not supported.
|
|
24
|
+
- Bare paths without URI schemes are rejected; use file:///... for local paths.
|
|
25
|
+
- Percent-encoded characters are preserved as-is. Storage adapters own decoding.
|
|
26
|
+
- to_str() emits canonical round-trippable strings:
|
|
27
|
+
file:///abs/path, memory:///test/path, s3://bucket/path, gs://bucket/path.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import posixpath
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
SUPPORTED_PROTOCOLS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"s3",
|
|
41
|
+
# gs retained for planned future GCS support; not currently advertised in CLI choices
|
|
42
|
+
"gs",
|
|
43
|
+
"file",
|
|
44
|
+
"memory",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
REMOTE_PROTOCOLS = frozenset({"s3", "gs"})
|
|
48
|
+
LOCAL_PROTOCOLS = frozenset({"file", "memory"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_protocol(protocol: str) -> str:
|
|
52
|
+
normalized = str(protocol or "").strip().lower()
|
|
53
|
+
if not normalized:
|
|
54
|
+
raise ValueError("a URI scheme is required (use file:///… or s3://…)")
|
|
55
|
+
if normalized not in SUPPORTED_PROTOCOLS:
|
|
56
|
+
supported = ", ".join(sorted(SUPPORTED_PROTOCOLS))
|
|
57
|
+
raise ValueError(f"unsupported protocol: {normalized!r}; supported: {supported}")
|
|
58
|
+
return normalized
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_path(path: str) -> str:
|
|
62
|
+
candidate = str(path or "")
|
|
63
|
+
if not candidate.startswith("/"):
|
|
64
|
+
candidate = f"/{candidate}"
|
|
65
|
+
candidate = re.sub(r"/+", "/", candidate)
|
|
66
|
+
collapsed = posixpath.normpath(candidate)
|
|
67
|
+
if collapsed == ".":
|
|
68
|
+
return "/"
|
|
69
|
+
return collapsed.rstrip("/") or "/"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class StorageUri:
|
|
74
|
+
protocol: str
|
|
75
|
+
authority: str | None
|
|
76
|
+
path: str
|
|
77
|
+
_root_had_slash: bool = field(default=True, init=False, repr=False, compare=False)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
protocol = _validate_protocol(self.protocol)
|
|
81
|
+
authority = str(self.authority) if self.authority is not None else None
|
|
82
|
+
if authority == "":
|
|
83
|
+
authority = None
|
|
84
|
+
if protocol == "file" and authority == "localhost":
|
|
85
|
+
authority = None
|
|
86
|
+
path = _normalize_path(self.path)
|
|
87
|
+
|
|
88
|
+
if protocol in REMOTE_PROTOCOLS and authority is None:
|
|
89
|
+
raise ValueError("authority (bucket) is required for s3/gs URIs")
|
|
90
|
+
if protocol in LOCAL_PROTOCOLS and authority is not None:
|
|
91
|
+
raise ValueError(f"authority is not supported for {protocol} URIs")
|
|
92
|
+
|
|
93
|
+
object.__setattr__(self, "protocol", protocol)
|
|
94
|
+
object.__setattr__(self, "authority", authority)
|
|
95
|
+
object.__setattr__(self, "path", path)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def parse(cls, raw: str) -> StorageUri:
|
|
99
|
+
candidate = str(raw or "")
|
|
100
|
+
if not candidate.strip():
|
|
101
|
+
raise ValueError("StorageUri input must not be empty")
|
|
102
|
+
|
|
103
|
+
parsed = urlparse(candidate)
|
|
104
|
+
if not parsed.scheme:
|
|
105
|
+
if candidate.startswith("/"):
|
|
106
|
+
raise ValueError("bare paths are not supported; use file:///…")
|
|
107
|
+
raise ValueError("a URI scheme is required (use file:///… or s3://…)")
|
|
108
|
+
if parsed.query:
|
|
109
|
+
raise ValueError("query strings not supported in storage URIs")
|
|
110
|
+
if parsed.fragment:
|
|
111
|
+
raise ValueError("fragments not supported in storage URIs")
|
|
112
|
+
|
|
113
|
+
protocol = _validate_protocol(parsed.scheme)
|
|
114
|
+
authority = parsed.netloc or None
|
|
115
|
+
if protocol == "file" and authority == "localhost":
|
|
116
|
+
authority = None
|
|
117
|
+
if protocol in REMOTE_PROTOCOLS and authority is None:
|
|
118
|
+
raise ValueError("missing authority: authority (bucket) is required for s3/gs URIs")
|
|
119
|
+
|
|
120
|
+
uri = cls(protocol=protocol, authority=authority, path=parsed.path)
|
|
121
|
+
object.__setattr__(uri, "_root_had_slash", bool(parsed.path))
|
|
122
|
+
return uri
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_local_path(cls, abs_path: str | Path) -> StorageUri:
|
|
126
|
+
path = Path(abs_path)
|
|
127
|
+
if not path.is_absolute():
|
|
128
|
+
raise ValueError("from_local_path requires an absolute path")
|
|
129
|
+
return cls(protocol="file", authority=None, path=path.as_posix())
|
|
130
|
+
|
|
131
|
+
def join(self, *segments: str) -> StorageUri:
|
|
132
|
+
cleaned_segments = [segment.strip("/") for segment in segments if segment.strip("/")]
|
|
133
|
+
if not cleaned_segments:
|
|
134
|
+
return self
|
|
135
|
+
joined_path = posixpath.join(self.path.rstrip("/"), *cleaned_segments)
|
|
136
|
+
return StorageUri(
|
|
137
|
+
protocol=self.protocol,
|
|
138
|
+
authority=self.authority,
|
|
139
|
+
path=joined_path,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def with_protocol(self, p: str) -> StorageUri:
|
|
143
|
+
protocol = _validate_protocol(p)
|
|
144
|
+
if protocol in REMOTE_PROTOCOLS and self.authority is None:
|
|
145
|
+
raise ValueError("authority (bucket) is required for s3/gs URIs")
|
|
146
|
+
if protocol in LOCAL_PROTOCOLS:
|
|
147
|
+
return StorageUri(protocol=protocol, authority=None, path=self.path)
|
|
148
|
+
return StorageUri(protocol=protocol, authority=self.authority, path=self.path)
|
|
149
|
+
|
|
150
|
+
def parent(self) -> StorageUri:
|
|
151
|
+
if self.path == "/":
|
|
152
|
+
return self
|
|
153
|
+
parent_path = posixpath.dirname(self.path)
|
|
154
|
+
parent = StorageUri(
|
|
155
|
+
protocol=self.protocol,
|
|
156
|
+
authority=self.authority,
|
|
157
|
+
path=parent_path or "/",
|
|
158
|
+
)
|
|
159
|
+
if parent.path == "/" and self.protocol in REMOTE_PROTOCOLS:
|
|
160
|
+
object.__setattr__(parent, "_root_had_slash", False)
|
|
161
|
+
return parent
|
|
162
|
+
|
|
163
|
+
def is_remote(self) -> bool:
|
|
164
|
+
return self.protocol in REMOTE_PROTOCOLS
|
|
165
|
+
|
|
166
|
+
def to_str(self) -> str:
|
|
167
|
+
if self.authority is None:
|
|
168
|
+
return f"{self.protocol}://{self.path}"
|
|
169
|
+
if self.path == "/":
|
|
170
|
+
if not self._root_had_slash:
|
|
171
|
+
return f"{self.protocol}://{self.authority}"
|
|
172
|
+
return f"{self.protocol}://{self.authority}/"
|
|
173
|
+
return f"{self.protocol}://{self.authority}{self.path}"
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
"""Tensogram integration for Firecube — optional dependency."""
|
|
16
|
+
|
|
17
|
+
from firecube.core.tensogram.schema import (
|
|
18
|
+
ARCHIVE_VERSION,
|
|
19
|
+
KEY_ARCHIVE_VERSION,
|
|
20
|
+
KEY_GROUP,
|
|
21
|
+
KEY_ROLE,
|
|
22
|
+
ROLE_CONTROLPLANE,
|
|
23
|
+
ROLE_DATA,
|
|
24
|
+
make_controlplane_meta,
|
|
25
|
+
make_data_meta,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ARCHIVE_VERSION",
|
|
30
|
+
"KEY_ARCHIVE_VERSION",
|
|
31
|
+
"KEY_GROUP",
|
|
32
|
+
"KEY_ROLE",
|
|
33
|
+
"ROLE_CONTROLPLANE",
|
|
34
|
+
"ROLE_DATA",
|
|
35
|
+
"make_controlplane_meta",
|
|
36
|
+
"make_data_meta",
|
|
37
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
"""Optional tensogram dependency with graceful fallback."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import tensogram # pyright: ignore[reportMissingImports]
|
|
21
|
+
import tensogram_xarray # pyright: ignore[reportMissingImports]
|
|
22
|
+
|
|
23
|
+
HAS_TENSOGRAM = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
HAS_TENSOGRAM = False
|
|
26
|
+
tensogram = None # type: ignore[assignment]
|
|
27
|
+
tensogram_xarray = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_tensogram(operation: str) -> None:
|
|
31
|
+
"""Raise ImportError with install instructions if tensogram is not available."""
|
|
32
|
+
if not HAS_TENSOGRAM:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
f"{operation} requires the tensogram extras. "
|
|
35
|
+
"Install with: uv pip install 'firecube[tensogram]'"
|
|
36
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
"""Control-plane serialization codec for Firecube .tgm archives.
|
|
16
|
+
|
|
17
|
+
Serializes .firecube/ state into a numpy uint8 array for embedding in .tgm
|
|
18
|
+
archives, and deserializes/restores it on the restore path.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from firecube.core.controlplane.manager import ChunkManager
|
|
33
|
+
|
|
34
|
+
_log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize_controlplane(
|
|
38
|
+
manager: ChunkManager,
|
|
39
|
+
product: str,
|
|
40
|
+
*,
|
|
41
|
+
group: str | None = None,
|
|
42
|
+
) -> tuple[dict[str, Any], np.ndarray]:
|
|
43
|
+
"""Serialize control-plane state for a product into a uint8 numpy array.
|
|
44
|
+
|
|
45
|
+
When *group* is specified only spans for that group are included.
|
|
46
|
+
Claims (write locks) are always included in full — they have no group field.
|
|
47
|
+
Run records are included only for runs referenced by the result set.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
tuple[dict, np.ndarray]
|
|
52
|
+
``(descriptor_dict, uint8_array)`` suitable for ``TensogramFile.append()``.
|
|
53
|
+
"""
|
|
54
|
+
list_kwargs: dict[str, Any] = {"product": product, "chunk_type": "span"}
|
|
55
|
+
if group is not None:
|
|
56
|
+
list_kwargs["meta"] = {"group": group}
|
|
57
|
+
|
|
58
|
+
spans = manager.list_chunks(**list_kwargs)
|
|
59
|
+
|
|
60
|
+
span_records: list[dict[str, Any]] = []
|
|
61
|
+
run_ids_seen: set[str] = set()
|
|
62
|
+
for chunk in spans:
|
|
63
|
+
span_meta = chunk.meta or {}
|
|
64
|
+
run_id = span_meta.get("run_id")
|
|
65
|
+
if run_id:
|
|
66
|
+
run_ids_seen.add(run_id)
|
|
67
|
+
span_records.append(
|
|
68
|
+
{
|
|
69
|
+
"key": chunk.key,
|
|
70
|
+
"type": chunk.chunk_type,
|
|
71
|
+
"status": chunk.status,
|
|
72
|
+
"meta": span_meta,
|
|
73
|
+
"record": chunk.record or {},
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
runs = manager.list_runs(product=product)
|
|
78
|
+
run_records: list[dict[str, Any]] = []
|
|
79
|
+
for run in runs:
|
|
80
|
+
if run_ids_seen and run.run_id not in run_ids_seen:
|
|
81
|
+
continue
|
|
82
|
+
run_records.append(
|
|
83
|
+
{
|
|
84
|
+
"run_id": run.run_id,
|
|
85
|
+
"status": run.status,
|
|
86
|
+
"started_at": run.started_at,
|
|
87
|
+
"updated_at": run.updated_at,
|
|
88
|
+
"completed_at": run.completed_at,
|
|
89
|
+
"events": run.events,
|
|
90
|
+
"parts": run.parts,
|
|
91
|
+
"product": run.product,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
claim_records: list[dict[str, Any]] = []
|
|
96
|
+
try:
|
|
97
|
+
claims = manager.list_claims(product=product)
|
|
98
|
+
claim_records.extend(
|
|
99
|
+
{
|
|
100
|
+
"domain": claim.domain,
|
|
101
|
+
"owner_id": claim.owner_id,
|
|
102
|
+
"acquired_at": claim.acquired_at,
|
|
103
|
+
}
|
|
104
|
+
for claim in claims
|
|
105
|
+
)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
_log.debug("Could not collect claims for %s: %s", product, exc)
|
|
108
|
+
|
|
109
|
+
state: dict[str, Any] = {
|
|
110
|
+
"schema_version": "v1",
|
|
111
|
+
"product": product,
|
|
112
|
+
"group_filter": group,
|
|
113
|
+
"spans": span_records,
|
|
114
|
+
"runs": run_records,
|
|
115
|
+
"claims": claim_records,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
data_bytes = json.dumps(state).encode("utf-8")
|
|
119
|
+
arr = np.frombuffer(data_bytes, dtype=np.uint8).copy()
|
|
120
|
+
descriptor: dict[str, Any] = {
|
|
121
|
+
"type": "ntensor",
|
|
122
|
+
"shape": [len(arr)],
|
|
123
|
+
"dtype": "uint8",
|
|
124
|
+
"compression": "none",
|
|
125
|
+
}
|
|
126
|
+
return descriptor, arr
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def deserialize_controlplane(data: np.ndarray) -> dict[str, Any]:
|
|
130
|
+
"""Deserialize a control-plane uint8 array back into a state dict."""
|
|
131
|
+
data_bytes = data.astype(np.uint8).tobytes()
|
|
132
|
+
return json.loads(data_bytes.decode("utf-8"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def restore_controlplane(
|
|
136
|
+
state: dict[str, Any],
|
|
137
|
+
target_path: str,
|
|
138
|
+
*,
|
|
139
|
+
storage_config: Any | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Write .firecube/ control-plane state to a target path.
|
|
142
|
+
|
|
143
|
+
Replays run and span records as WAL events under
|
|
144
|
+
``<target_path>/.firecube/`` using ``RunEventWriter``.
|
|
145
|
+
|
|
146
|
+
This is best-effort: individual run failures are logged as warnings
|
|
147
|
+
and do not prevent other runs from being restored.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
state:
|
|
152
|
+
Deserialized control-plane state dict from ``deserialize_controlplane()``.
|
|
153
|
+
target_path:
|
|
154
|
+
Root path of the zarr store to restore ``.firecube/`` into.
|
|
155
|
+
storage_config:
|
|
156
|
+
Optional ``StorageConfig`` used to wire the underlying filesystem with
|
|
157
|
+
the configured endpoint and credentials. When ``None``, ambient defaults
|
|
158
|
+
apply — required for remote ``target_path`` values such as ``s3://...``.
|
|
159
|
+
"""
|
|
160
|
+
from firecube.core.controlplane.events import RunEventWriter
|
|
161
|
+
from firecube.core.filesystem import StorageFilesystemFull, create_filesystem
|
|
162
|
+
from firecube.core.product.identity import ProductIdentity
|
|
163
|
+
from firecube.core.storage.binding import StorageBinding
|
|
164
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
165
|
+
from firecube.core.storage.uri import StorageUri
|
|
166
|
+
|
|
167
|
+
target_uri = (
|
|
168
|
+
StorageUri.parse(target_path)
|
|
169
|
+
if "://" in target_path
|
|
170
|
+
else StorageUri.from_local_path(Path(target_path).resolve())
|
|
171
|
+
)
|
|
172
|
+
control_uri = target_uri.join(".firecube")
|
|
173
|
+
driver = StorageDriverConfig.from_storage_config_or_default(storage_config)
|
|
174
|
+
fs = cast(
|
|
175
|
+
StorageFilesystemFull,
|
|
176
|
+
create_filesystem(
|
|
177
|
+
StorageBinding(
|
|
178
|
+
identity=ProductIdentity.from_uri(target_uri, "zarr", product_name=target_path),
|
|
179
|
+
driver=driver,
|
|
180
|
+
)
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
product = state.get("product", "unknown")
|
|
185
|
+
spans = state.get("spans", [])
|
|
186
|
+
runs = state.get("runs", [])
|
|
187
|
+
|
|
188
|
+
if not spans and not runs:
|
|
189
|
+
_log.info("No control-plane records to restore for %s", product)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
spans_by_run: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
193
|
+
for span in spans:
|
|
194
|
+
run_id = span.get("meta", {}).get("run_id", "unknown")
|
|
195
|
+
spans_by_run[run_id].append(span)
|
|
196
|
+
|
|
197
|
+
runs_by_id = {r["run_id"]: r for r in runs}
|
|
198
|
+
|
|
199
|
+
for run_id, run_spans in spans_by_run.items():
|
|
200
|
+
run_info = runs_by_id.get(run_id, {})
|
|
201
|
+
try:
|
|
202
|
+
writer = RunEventWriter(
|
|
203
|
+
fs=fs,
|
|
204
|
+
control_uri=control_uri,
|
|
205
|
+
product=product,
|
|
206
|
+
run_id=run_id,
|
|
207
|
+
resume_meta=run_info if run_info else None,
|
|
208
|
+
)
|
|
209
|
+
for span in run_spans:
|
|
210
|
+
writer.append(
|
|
211
|
+
event_type="span_recorded",
|
|
212
|
+
record=span.get("record", {}),
|
|
213
|
+
meta=span.get("meta", {}),
|
|
214
|
+
)
|
|
215
|
+
original_status = run_info.get("status", "complete")
|
|
216
|
+
writer.finalize(status=original_status)
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
_log.warning(
|
|
219
|
+
"Could not restore WAL events for run %s in %s: %s",
|
|
220
|
+
run_id,
|
|
221
|
+
product,
|
|
222
|
+
exc,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
_log.info(
|
|
226
|
+
"Restored .firecube/ state for %s: %d spans across %d runs",
|
|
227
|
+
product,
|
|
228
|
+
len(spans),
|
|
229
|
+
len(runs_by_id),
|
|
230
|
+
)
|