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,530 @@
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
+ """Helpers for building Intake catalogs for Firecube products.
16
+
17
+ Firecube owns catalog generation. It discovers catalogable dataset groups for
18
+ supported store formats, lets plugins optionally annotate or hide those groups,
19
+ and renders a generic Intake catalog from the resulting source list.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import PurePosixPath
28
+ from typing import Any
29
+ from urllib.parse import urlparse
30
+
31
+ import xarray as xr
32
+
33
+ from firecube.core.config import StorageConfig
34
+ from firecube.core.filesystem import create_filesystem
35
+ from firecube.core.filesystem.store_factory import ZarrStoreHandle
36
+ from firecube.core.product.identity import ProductIdentity
37
+ from firecube.core.storage.binding import StorageBinding
38
+ from firecube.core.storage.driver_config import StorageDriverConfig
39
+ from firecube.core.storage.session import StorageSession
40
+ from firecube.core.storage.uri import StorageUri
41
+ from firecube.core.uris import is_remote_target, storage_uri_from_target
42
+ from firecube.core.zarr.validation import discover_groups
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class CatalogGroupInfo:
47
+ """Optional plugin annotations for one discovered catalog group."""
48
+
49
+ include: bool = True
50
+ name: str | None = None
51
+ description: str | None = None
52
+ metadata: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class CatalogSourceSpec:
57
+ """Description of a single Intake source backed by one discovered group."""
58
+
59
+ name: str
60
+ description: str
61
+ group: str
62
+ data_format: str = "zarr"
63
+ metadata: dict[str, Any] = field(default_factory=dict)
64
+
65
+
66
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
67
+
68
+
69
+ def _group_path(store_root: str, group: str) -> str:
70
+ group_clean = (group or "").strip("/")
71
+ return store_root.rstrip("/") if not group_clean else f"{store_root.rstrip('/')}/{group_clean}"
72
+
73
+
74
+ def _group_target_uri(store_uri: str, group: str) -> str:
75
+ group_clean = (group or "").strip("/")
76
+ return store_uri.rstrip("/") if not group_clean else f"{store_uri.rstrip('/')}/{group_clean}"
77
+
78
+
79
+ def _storage_config_for_target(
80
+ target_uri: str,
81
+ storage_config: Any | None,
82
+ storage_session: StorageSession | None = None,
83
+ ) -> StorageConfig:
84
+ if storage_config is not None:
85
+ return storage_config
86
+ if storage_session is not None:
87
+ credentials = storage_session.driver.credentials
88
+ return StorageConfig(
89
+ storage_type="s3" if storage_session.product.product_uri.is_remote() else "local",
90
+ endpoint_url=storage_session.driver.endpoint_url,
91
+ access_key=credentials.access_key if credentials is not None else None,
92
+ secret_key=credentials.secret_key if credentials is not None else None,
93
+ region=storage_session.driver.region,
94
+ path_style=storage_session.driver.path_style,
95
+ storage_driver=storage_session.driver.driver,
96
+ )
97
+ raise ValueError(
98
+ "storage_config is required for catalog generation; pass --storage-type and --storage-driver"
99
+ )
100
+
101
+
102
+ def _session_for_target(
103
+ target_uri: str,
104
+ storage_config: Any | None,
105
+ storage_session: StorageSession | None = None,
106
+ ) -> StorageSession:
107
+ if storage_session is not None:
108
+ return storage_session
109
+ store_uri = storage_uri_from_target(target_uri)
110
+ cfg = _storage_config_for_target(target_uri, storage_config)
111
+ return StorageSession(
112
+ StorageBinding(
113
+ identity=ProductIdentity.from_uri(store_uri, format="zarr", product_name=target_uri),
114
+ driver=StorageDriverConfig.from_storage_config(cfg),
115
+ )
116
+ )
117
+
118
+
119
+ def _native_path_from_storage_uri(uri: StorageUri) -> str:
120
+ if uri.protocol == "file":
121
+ return uri.path
122
+ return f"{uri.authority}{uri.path}"
123
+
124
+
125
+ def _normalize_source_suffix(group: str) -> str:
126
+ text = (group or "").strip("/")
127
+ if not text:
128
+ return "root"
129
+ return _NON_ALNUM_RE.sub("_", text.lower()).strip("_") or "root"
130
+
131
+
132
+ def default_catalog_source_name(plugin_name: str, group: str) -> str:
133
+ """Return a deterministic Intake source name for a catalog group."""
134
+ return f"{plugin_name}_{_normalize_source_suffix(group)}"
135
+
136
+
137
+ def default_catalog_description(product: str, group: str) -> str:
138
+ """Return a generic human-readable description for a catalog group."""
139
+ group_label = (group or "").strip("/") or "/"
140
+ return f"Firecube product '{product}' group '{group_label}'."
141
+
142
+
143
+ def default_catalog_metadata(plugin_name: str, product: str, group: str) -> dict[str, Any]:
144
+ """Return generic structural metadata for a discovered group."""
145
+ cleaned = (group or "").strip("/")
146
+ segments = cleaned.split("/") if cleaned else []
147
+ return {
148
+ "plugin": plugin_name,
149
+ "product": product,
150
+ "group": cleaned or "/",
151
+ "group_segments": segments,
152
+ "group_depth": len(segments),
153
+ }
154
+
155
+
156
+ def detect_catalog_data_format(store_uri: str) -> str:
157
+ """Infer the catalog data format from the product URI suffix."""
158
+ lowered = str(store_uri).lower().rstrip("/")
159
+ if lowered.endswith(".zarr"):
160
+ return "zarr"
161
+ if lowered.endswith(".parquet"):
162
+ return "parquet"
163
+ raise ValueError(
164
+ "Unsupported catalog product format. Expected a '.zarr' or '.parquet' product, "
165
+ f"got '{store_uri}'."
166
+ )
167
+
168
+
169
+ def _read_node_type(
170
+ store_uri: str,
171
+ group: str,
172
+ storage_config: Any | None = None,
173
+ storage_session: StorageSession | None = None,
174
+ ) -> str | None:
175
+ import zarr
176
+
177
+ handle = _session_for_target(store_uri, storage_config, storage_session).zarr.create_store(
178
+ uri=storage_uri_from_target(store_uri),
179
+ mode="r",
180
+ )
181
+ try:
182
+ node = zarr.open(**handle.zarr_kwargs(), path=(group or None), mode="r")
183
+ except Exception:
184
+ return None
185
+ payload = getattr(node, "metadata", None)
186
+ if payload is None:
187
+ return None
188
+ to_dict = getattr(payload, "to_dict", None)
189
+ if callable(to_dict):
190
+ payload = to_dict()
191
+ if not isinstance(payload, dict):
192
+ return None
193
+ node_type = payload.get("node_type")
194
+ return str(node_type) if node_type is not None else None
195
+
196
+
197
+ def _resolve_dataset_store_handle(
198
+ target_uri: str,
199
+ storage_config: Any | None,
200
+ storage_session: StorageSession | None = None,
201
+ ) -> ZarrStoreHandle:
202
+ """Build a uniform zarr-store handle for ``target_uri``.
203
+
204
+ Routes remote targets through the session Zarr API so the configured
205
+ endpoint and credentials are honored on every zarr open. Local targets and
206
+ config-less calls short-circuit to a plain ``ZarrStoreHandle`` with the URI
207
+ as the store and ``storage_options=None`` (mirrors the converter's
208
+ ``_resolve_source_store`` pattern).
209
+ """
210
+ if not is_remote_target(target_uri) or storage_config is None:
211
+ return ZarrStoreHandle(store=target_uri, storage_options=None, target_uri=target_uri)
212
+ return _session_for_target(target_uri, storage_config, storage_session).zarr.create_store(
213
+ uri=storage_uri_from_target(target_uri),
214
+ mode="r",
215
+ )
216
+
217
+
218
+ def _contains_zarr_arrays(
219
+ target_uri: str,
220
+ storage_config: Any | None,
221
+ storage_session: StorageSession | None = None,
222
+ ) -> bool:
223
+ import zarr
224
+
225
+ handle = _resolve_dataset_store_handle(target_uri, storage_config, storage_session)
226
+ try:
227
+ node = zarr.open(**handle.zarr_kwargs(), mode="r")
228
+ except Exception:
229
+ return False
230
+ if not isinstance(node, zarr.Group):
231
+ return False
232
+ return any(True for _name, _array in node.arrays())
233
+
234
+
235
+ def _is_readable_dataset_group(
236
+ store_uri: str,
237
+ group: str,
238
+ *,
239
+ storage_config: Any | None = None,
240
+ storage_session: StorageSession | None = None,
241
+ ) -> bool:
242
+ target_uri = _group_target_uri(store_uri, group)
243
+ handle = _resolve_dataset_store_handle(target_uri, storage_config, storage_session)
244
+ open_kwargs = {**handle.zarr_kwargs(), "group": None, "chunks": None, "consolidated": False}
245
+ try:
246
+ ds = xr.open_zarr(**open_kwargs) # pyright: ignore[reportArgumentType]
247
+ except Exception:
248
+ return _contains_zarr_arrays(target_uri, storage_config, storage_session)
249
+ try:
250
+ return len(ds.data_vars) > 0
251
+ finally:
252
+ with contextlib.suppress(Exception):
253
+ ds.close()
254
+
255
+
256
+ def discover_catalog_groups(
257
+ store_uri: str,
258
+ *,
259
+ storage_config: Any | None = None,
260
+ storage_session: StorageSession | None = None,
261
+ max_depth: int = 8,
262
+ ) -> list[str]:
263
+ """Discover catalogable groups within a supported store."""
264
+ data_format = detect_catalog_data_format(store_uri)
265
+ if data_format == "parquet":
266
+ return discover_parquet_catalog_groups(
267
+ store_uri,
268
+ storage_config=storage_config,
269
+ storage_session=storage_session,
270
+ )
271
+
272
+ storage_config_for_discovery = _storage_config_for_target(
273
+ store_uri,
274
+ storage_config,
275
+ storage_session,
276
+ )
277
+
278
+ readable: list[str] = []
279
+ for group in discover_groups(
280
+ store_uri,
281
+ storage_config=storage_config_for_discovery,
282
+ max_depth=max_depth,
283
+ strict=True,
284
+ ):
285
+ node_type = _read_node_type(
286
+ store_uri,
287
+ group,
288
+ storage_config=storage_config_for_discovery,
289
+ storage_session=storage_session,
290
+ )
291
+ if node_type == "array":
292
+ continue
293
+ if _is_readable_dataset_group(
294
+ store_uri,
295
+ group,
296
+ storage_config=storage_config_for_discovery,
297
+ storage_session=storage_session,
298
+ ):
299
+ readable.append(group)
300
+ return sorted(set(readable))
301
+
302
+
303
+ def discover_parquet_catalog_groups(
304
+ store_uri: str,
305
+ *,
306
+ storage_config: Any | None = None,
307
+ storage_session: StorageSession | None = None,
308
+ ) -> list[str]:
309
+ """Discover leaf parquet groups within a parquet product root."""
310
+ store_storage_uri = storage_uri_from_target(store_uri)
311
+ if storage_session is not None:
312
+ fs = storage_session.fs()
313
+ else:
314
+ cfg = _storage_config_for_target(store_uri, storage_config)
315
+ binding = StorageBinding(
316
+ identity=ProductIdentity.from_uri(
317
+ store_storage_uri,
318
+ format="parquet",
319
+ product_name=store_uri,
320
+ ),
321
+ driver=StorageDriverConfig.from_storage_config(cfg),
322
+ )
323
+ fs = create_filesystem(binding)
324
+ root_clean = _native_path_from_storage_uri(store_storage_uri).rstrip("/")
325
+ if not root_clean:
326
+ return []
327
+
328
+ try:
329
+ if fs.exists(store_storage_uri) and not fs.isdir(store_storage_uri):
330
+ return [""]
331
+ except Exception:
332
+ pass
333
+
334
+ try:
335
+ candidates = fs.find(store_storage_uri)
336
+ except Exception:
337
+ candidates = []
338
+
339
+ groups: set[str] = set()
340
+ root_path = PurePosixPath(root_clean)
341
+ for candidate in candidates:
342
+ normalized = (
343
+ _native_path_from_storage_uri(candidate)
344
+ if isinstance(candidate, StorageUri)
345
+ else str(candidate)
346
+ ).rstrip("/")
347
+ if not normalized.endswith(".parquet"):
348
+ continue
349
+ if "/.firecube/" in f"/{normalized}/":
350
+ continue
351
+ candidate_path = PurePosixPath(normalized)
352
+ parent = candidate_path.parent
353
+ try:
354
+ relative = parent.relative_to(root_path)
355
+ except ValueError:
356
+ if normalized == root_clean:
357
+ groups.add("")
358
+ continue
359
+ relative_text = str(relative)
360
+ groups.add("" if relative_text == "." else relative_text.strip("/"))
361
+
362
+ return sorted(groups)
363
+
364
+
365
+ def build_catalog_source_specs(
366
+ *,
367
+ plugin_name: str,
368
+ product: str,
369
+ store_uri: str,
370
+ groups: list[str],
371
+ group_info_resolver: Any | None = None,
372
+ storage_config: Any | None = None,
373
+ storage_session: StorageSession | None = None,
374
+ ) -> list[CatalogSourceSpec]:
375
+ """Build catalog source specs from discovered groups and optional plugin annotations."""
376
+ data_format = detect_catalog_data_format(store_uri)
377
+ resolver_storage_config = (
378
+ _storage_config_for_target(store_uri, storage_config, storage_session)
379
+ if group_info_resolver is not None
380
+ else None
381
+ )
382
+ specs: list[CatalogSourceSpec] = []
383
+ for group in groups:
384
+ info = None
385
+ if callable(group_info_resolver):
386
+ info = group_info_resolver(group, store_uri, storage_config=resolver_storage_config)
387
+ if info is None:
388
+ info = CatalogGroupInfo()
389
+ if not isinstance(info, CatalogGroupInfo):
390
+ raise TypeError(
391
+ "catalog_group_info() must return CatalogGroupInfo or None, "
392
+ f"got {type(info).__name__}."
393
+ )
394
+ if not info.include:
395
+ continue
396
+
397
+ metadata = default_catalog_metadata(plugin_name, product, group)
398
+ metadata["data_format"] = data_format
399
+ metadata.update(info.metadata)
400
+ specs.append(
401
+ CatalogSourceSpec(
402
+ name=info.name or default_catalog_source_name(plugin_name, group),
403
+ description=info.description or default_catalog_description(product, group),
404
+ group=group,
405
+ data_format=data_format,
406
+ metadata=metadata,
407
+ )
408
+ )
409
+ return specs
410
+
411
+
412
+ def _catalog_entry_args(
413
+ *,
414
+ store_uri: str,
415
+ spec: CatalogSourceSpec,
416
+ storage_opts: dict[str, Any],
417
+ ) -> dict[str, Any]:
418
+ if spec.data_format == "parquet":
419
+ args: dict[str, Any] = {
420
+ "urlpath": _group_target_uri(store_uri, spec.group),
421
+ "engine": "pyarrow",
422
+ }
423
+ if storage_opts:
424
+ args["storage_options"] = storage_opts
425
+ return args
426
+
427
+ args = {
428
+ "urlpath": store_uri,
429
+ "group": spec.group,
430
+ "consolidated": False,
431
+ "chunks": "auto",
432
+ }
433
+ if storage_opts:
434
+ args["storage_options"] = storage_opts
435
+ return args
436
+
437
+
438
+ def _catalog_entry_driver(spec: CatalogSourceSpec) -> str:
439
+ if spec.data_format == "parquet":
440
+ return "parquet"
441
+ # Use the registered Intake driver short name (entry point ``zarr`` ->
442
+ # ``intake_xarray.xzarr:ZarrSource``). A full module path such as
443
+ # ``intake_xarray.zarr.ZarrSource`` is not resolvable by Intake and fails
444
+ # at open time with "No plugins loaded for this entry".
445
+ return "zarr"
446
+
447
+
448
+ def _default_storage_options(store_uri: str) -> dict[str, Any]:
449
+ """Build a generic storage_options block for Intake.
450
+
451
+ Notes
452
+ -----
453
+ To keep the catalog portable and avoid embedding secrets, this helper
454
+ uses environment placeholders. Users are expected to export the matching
455
+ FIRECUBE_* variables in their environment when opening the catalog.
456
+
457
+ Canonical variables (mirrors `export_storage_config_to_env`):
458
+ - FIRECUBE_ENDPOINT_URL
459
+ - FIRECUBE_ACCESS_KEY
460
+ - FIRECUBE_SECRET_KEY
461
+ """
462
+ parsed = urlparse(store_uri)
463
+ if parsed.scheme != "s3":
464
+ # Local/other backends rely on fsspec/xarray defaults and env.
465
+ return {}
466
+
467
+ return {
468
+ "anon": False,
469
+ "client_kwargs": {
470
+ "endpoint_url": "${FIRECUBE_ENDPOINT_URL}",
471
+ },
472
+ "key": "${FIRECUBE_ACCESS_KEY}",
473
+ # env-var template placeholder rendered downstream, not a credential value
474
+ "secret": "${FIRECUBE_SECRET_KEY}", # nosec B105
475
+ }
476
+
477
+
478
+ def build_intake_catalog(
479
+ *,
480
+ catalog_name: str,
481
+ catalog_description: str,
482
+ collection_id: str,
483
+ store_uri: str,
484
+ sources: list[CatalogSourceSpec],
485
+ include_storage_options: bool = True,
486
+ ) -> dict[str, Any]:
487
+ """Construct an Intake catalog dictionary for the given product store.
488
+
489
+ Parameters
490
+ ----------
491
+ catalog_name:
492
+ Logical name for the catalog (e.g. plugin id or product name).
493
+ catalog_description:
494
+ High-level description of the catalog.
495
+ store_uri:
496
+ Root URI of the store, such as ``s3://bucket/product.zarr`` or
497
+ ``s3://bucket/product.parquet``.
498
+ sources:
499
+ List of CatalogSourceSpec entries describing which groups to expose.
500
+ include_storage_options:
501
+ When True, attach a generic ``storage_options`` block for S3 stores
502
+ based on FIRECUBE_* environment placeholders.
503
+ """
504
+ catalog: dict[str, Any] = {
505
+ "metadata": {
506
+ "version": 1,
507
+ "name": catalog_name,
508
+ "description": catalog_description,
509
+ "collection_id": collection_id,
510
+ },
511
+ "sources": {},
512
+ }
513
+
514
+ storage_opts = _default_storage_options(store_uri) if include_storage_options else {}
515
+
516
+ for spec in sources:
517
+ source_entry: dict[str, Any] = {
518
+ "description": spec.description,
519
+ "driver": _catalog_entry_driver(spec),
520
+ "args": _catalog_entry_args(store_uri=store_uri, spec=spec, storage_opts=storage_opts),
521
+ }
522
+
523
+ source_metadata = dict(spec.metadata)
524
+ source_metadata["collection_id"] = collection_id
525
+ if source_metadata:
526
+ source_entry["metadata"] = source_metadata
527
+
528
+ catalog["sources"][spec.name] = source_entry
529
+
530
+ return catalog
@@ -0,0 +1,72 @@
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
+ """Firecube observability helpers (logging, tracing, telemetry).
16
+
17
+ This package is the single public surface area used by CLI/API entrypoints.
18
+ Plugins should only interact with observability via the injected
19
+ `IngestContext.telemetry` sink (see `telemetry/`).
20
+ """
21
+
22
+ from firecube.core.observability._state import is_initialized, set_initialized
23
+ from firecube.core.observability.logging import configure_logging
24
+ from firecube.core.observability.telemetry import (
25
+ IngestionTelemetry,
26
+ NoopIngestionTelemetry,
27
+ create_ingestion_telemetry,
28
+ )
29
+ from firecube.core.observability.tracing import (
30
+ attach_context,
31
+ capture_context,
32
+ configure_tracing,
33
+ detach_context,
34
+ propagated_context,
35
+ set_current_span_attribute,
36
+ shutdown_tracing,
37
+ span,
38
+ )
39
+
40
+
41
+ def init_observability(service_name: str = "firecube-service"):
42
+ """Unified entrypoint for logs and tracing (idempotent per process)."""
43
+ if is_initialized():
44
+ return
45
+ configure_logging()
46
+ configure_tracing(service_name)
47
+ set_initialized(True)
48
+
49
+
50
+ def shutdown_observability(timeout_millis: int = 5000) -> None:
51
+ """Shut down all observability providers and reset the initialized flag."""
52
+ shutdown_tracing(timeout_millis=timeout_millis)
53
+ set_initialized(False)
54
+
55
+
56
+ __all__ = [
57
+ "IngestionTelemetry",
58
+ "NoopIngestionTelemetry",
59
+ "attach_context",
60
+ "capture_context",
61
+ "configure_logging",
62
+ "configure_tracing",
63
+ "create_ingestion_telemetry",
64
+ "detach_context",
65
+ "init_observability",
66
+ "is_initialized",
67
+ "propagated_context",
68
+ "set_current_span_attribute",
69
+ "set_initialized",
70
+ "shutdown_observability",
71
+ "span",
72
+ ]
@@ -0,0 +1,24 @@
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
+ _OBS_INITIALIZED = False
16
+
17
+
18
+ def is_initialized() -> bool:
19
+ return bool(_OBS_INITIALIZED)
20
+
21
+
22
+ def set_initialized(value: bool) -> None:
23
+ global _OBS_INITIALIZED
24
+ _OBS_INITIALIZED = bool(value)