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
firecube/core/intake.py
ADDED
|
@@ -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)
|