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