firecube 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
"""Reusable lifecycle and slice-metadata hooks for BaseIngestor."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from firecube.ingestor.types.context import (
|
|
23
|
+
PipelineBatch,
|
|
24
|
+
PipelineResult,
|
|
25
|
+
PipelineRunState,
|
|
26
|
+
PluginContext,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _product_name(ctx: PluginContext, default: str) -> str:
|
|
31
|
+
storage = ctx.storage
|
|
32
|
+
if storage is not None and storage.output is not None:
|
|
33
|
+
return str(storage.output.product.product_name)
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canonicalize_slice_value(value: Any) -> Any:
|
|
38
|
+
if isinstance(value, set):
|
|
39
|
+
value = list(value)
|
|
40
|
+
if isinstance(value, tuple):
|
|
41
|
+
value = list(value)
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
try:
|
|
44
|
+
return sorted(value)
|
|
45
|
+
except TypeError:
|
|
46
|
+
return list(value)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BaseIngestorHookMixin:
|
|
51
|
+
"""Shared non-engine hooks used by BaseIngestor implementations."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
_log: logging.Logger
|
|
55
|
+
_span_recorder: Any
|
|
56
|
+
|
|
57
|
+
def slice_meta_keys(self) -> list[str]:
|
|
58
|
+
"""Option keys that define a logical slice for this plugin."""
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
def slice_meta(self, ctx: PluginContext) -> dict[str, Any]:
|
|
62
|
+
"""Return canonical slice metadata for this run."""
|
|
63
|
+
meta: dict[str, Any] = {}
|
|
64
|
+
for key in self.slice_meta_keys():
|
|
65
|
+
if key in ctx.options:
|
|
66
|
+
meta[key] = _canonicalize_slice_value(ctx.options.get(key))
|
|
67
|
+
return meta
|
|
68
|
+
|
|
69
|
+
def validation_group(self, ctx: PluginContext) -> str | None:
|
|
70
|
+
"""Optional hook to derive a Zarr group path for validate_zarr."""
|
|
71
|
+
_ = ctx
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def on_pipeline_start(self, ctx: PluginContext, state: PipelineRunState) -> None:
|
|
75
|
+
"""Called before pipeline execution starts."""
|
|
76
|
+
_ = ctx
|
|
77
|
+
self._log.info(
|
|
78
|
+
"Starting pipeline execution workers=%d batch_size=%d",
|
|
79
|
+
state.pipeline_workers,
|
|
80
|
+
state.batch_size,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def on_batch_success(
|
|
84
|
+
self,
|
|
85
|
+
ctx: PluginContext,
|
|
86
|
+
state: PipelineRunState,
|
|
87
|
+
batch: PipelineBatch,
|
|
88
|
+
result: PipelineResult,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Called on successful batch completion (engine-owned bookkeeping)."""
|
|
91
|
+
_ = state
|
|
92
|
+
slice_meta = self.slice_meta(ctx)
|
|
93
|
+
slice_meta.setdefault("plugin", self.name)
|
|
94
|
+
|
|
95
|
+
run_id = ctx.run_id or str(ctx.option("run_id", "unknown"))
|
|
96
|
+
product = _product_name(ctx, self.name)
|
|
97
|
+
|
|
98
|
+
self._span_recorder.record_batch_success(
|
|
99
|
+
ctx=ctx,
|
|
100
|
+
batch=batch,
|
|
101
|
+
result=result,
|
|
102
|
+
slice_meta=slice_meta,
|
|
103
|
+
run_id=run_id,
|
|
104
|
+
product=product,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def on_batch_failure(
|
|
108
|
+
self,
|
|
109
|
+
ctx: PluginContext,
|
|
110
|
+
state: PipelineRunState,
|
|
111
|
+
batch: PipelineBatch,
|
|
112
|
+
result: PipelineResult,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Called on batch failure (engine-owned bookkeeping)."""
|
|
115
|
+
_ = state
|
|
116
|
+
slice_meta = self.slice_meta(ctx)
|
|
117
|
+
slice_meta.setdefault("plugin", self.name)
|
|
118
|
+
|
|
119
|
+
run_id = ctx.run_id or str(ctx.option("run_id", "unknown"))
|
|
120
|
+
product = _product_name(ctx, self.name)
|
|
121
|
+
|
|
122
|
+
self._span_recorder.record_batch_failure(
|
|
123
|
+
ctx=ctx,
|
|
124
|
+
batch=batch,
|
|
125
|
+
error=result.error,
|
|
126
|
+
slice_meta=slice_meta,
|
|
127
|
+
run_id=run_id,
|
|
128
|
+
product=product,
|
|
129
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
"""Batch creation logic for ingestion pipelines.
|
|
16
|
+
|
|
17
|
+
This module provides the `BatchPlanner` service, which is responsible for
|
|
18
|
+
discovering source files, filtering them, grouping them, and chunking them
|
|
19
|
+
into batches for processing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Iterable, Iterator
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from firecube.ingestor.contracts.interfaces import SourceFile
|
|
31
|
+
from firecube.ingestor.types.context import PipelineBatch, PluginContext
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("firecube.ingestor.batching")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class BatchPlanHost(Protocol):
|
|
38
|
+
"""Protocol for the host that drives the batch planning process.
|
|
39
|
+
|
|
40
|
+
The host provides the source files and the logic for filtering and grouping them.
|
|
41
|
+
Typically implemented by `BaseIngestor` or a subclass.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def batch_id_prefix(self) -> str:
|
|
46
|
+
"""prefix for batch IDs (e.g., 'plugin_name_')."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def discover_source_files(self, ctx: PluginContext) -> Iterable[Any]:
|
|
50
|
+
"""Yield source files (paths, strings, or objects) to be processed."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def filter_item(self, item: Any, ctx: PluginContext) -> bool:
|
|
54
|
+
"""Return True if the item should be processed, False otherwise."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def item_size_bytes(self, item: Any) -> int | None:
|
|
58
|
+
"""Return the size of the item in bytes, or None if unknown."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def get_batch_groups(self, items: list[Any], ctx: PluginContext) -> list[str]:
|
|
62
|
+
"""Return logical groups for a batch of items (e.g., horizon, product).
|
|
63
|
+
|
|
64
|
+
MUST be deterministic (return a sorted/stable list) to ensure consistent
|
|
65
|
+
spans and batch grouping across runs.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BatchPlanner:
|
|
71
|
+
"""Service that creates batches from discovered source files."""
|
|
72
|
+
|
|
73
|
+
def create_batches(
|
|
74
|
+
self, host: BatchPlanHost, ctx: PluginContext, batch_size: int
|
|
75
|
+
) -> Iterator[PipelineBatch]:
|
|
76
|
+
"""Yield batches via hooks (iter -> filter -> batch).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
host: The host object providing hooks and source files.
|
|
80
|
+
ctx: The ingestion context.
|
|
81
|
+
batch_size: Maximum number of items per batch.
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
PipelineBatch objects containing the items to be processed.
|
|
85
|
+
"""
|
|
86
|
+
items_iter = host.discover_source_files(ctx)
|
|
87
|
+
|
|
88
|
+
current_batch_items: list[Any] = []
|
|
89
|
+
current_size = 0
|
|
90
|
+
batch_idx = 0
|
|
91
|
+
|
|
92
|
+
def _yield_batch(b_items: list[Any], b_size: int, b_idx: int) -> PipelineBatch:
|
|
93
|
+
# Deterministic grouping is enforced by the host contract,
|
|
94
|
+
# but we trust the host to return a stable list.
|
|
95
|
+
groups = host.get_batch_groups(b_items, ctx)
|
|
96
|
+
|
|
97
|
+
# ID Strategy: {prefix}batch_{index:04d} (Simple IDs)
|
|
98
|
+
batch_id = f"{host.batch_id_prefix}batch_{b_idx:04d}"
|
|
99
|
+
|
|
100
|
+
# Safety: Store item URIs in metadata for logging/manifests.
|
|
101
|
+
# We assume items are str/Path/SourceFile, so str(item) or .uri is reasonable.
|
|
102
|
+
item_uris = []
|
|
103
|
+
for item in b_items:
|
|
104
|
+
if isinstance(item, SourceFile):
|
|
105
|
+
item_uris.append(item.uri)
|
|
106
|
+
else:
|
|
107
|
+
item_uris.append(str(item))
|
|
108
|
+
item_uris_hash = hashlib.sha256(
|
|
109
|
+
"\n".join(sorted(item_uris)).encode("utf-8")
|
|
110
|
+
).hexdigest()
|
|
111
|
+
|
|
112
|
+
# Limit metadata size for safety
|
|
113
|
+
preview_limit = 100
|
|
114
|
+
item_uris_total = len(item_uris)
|
|
115
|
+
item_uris_truncated = False
|
|
116
|
+
if item_uris_total > preview_limit:
|
|
117
|
+
item_uris_truncated = True
|
|
118
|
+
item_uris = [*item_uris[:preview_limit], "..."]
|
|
119
|
+
log.warning(
|
|
120
|
+
"Batch metadata item_uris truncated (batch_id=%s total=%d preview_limit=%d).",
|
|
121
|
+
batch_id,
|
|
122
|
+
item_uris_total,
|
|
123
|
+
preview_limit,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return PipelineBatch(
|
|
127
|
+
batch_id=batch_id,
|
|
128
|
+
data_path=Path(ctx.source),
|
|
129
|
+
items=list(b_items), # Copy to avoid mutation issues
|
|
130
|
+
metadata={
|
|
131
|
+
"item_uris": item_uris,
|
|
132
|
+
"item_uris_total": item_uris_total,
|
|
133
|
+
"item_uris_preview_limit": preview_limit,
|
|
134
|
+
"item_uris_truncated": item_uris_truncated,
|
|
135
|
+
"files_hash": item_uris_hash,
|
|
136
|
+
},
|
|
137
|
+
size_bytes=b_size,
|
|
138
|
+
files_count=len(b_items),
|
|
139
|
+
groups=groups,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for item in items_iter:
|
|
143
|
+
if not host.filter_item(item, ctx):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Calculate size (Best effort)
|
|
147
|
+
size = host.item_size_bytes(item)
|
|
148
|
+
if size is None:
|
|
149
|
+
size = 0
|
|
150
|
+
|
|
151
|
+
current_batch_items.append(item)
|
|
152
|
+
current_size += size
|
|
153
|
+
|
|
154
|
+
if len(current_batch_items) >= batch_size:
|
|
155
|
+
yield _yield_batch(current_batch_items, current_size, batch_idx)
|
|
156
|
+
batch_idx += 1
|
|
157
|
+
current_batch_items = []
|
|
158
|
+
current_size = 0
|
|
159
|
+
|
|
160
|
+
if current_batch_items:
|
|
161
|
+
yield _yield_batch(current_batch_items, current_size, batch_idx)
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
"""Options-tier configuration and execution-mode selection.
|
|
16
|
+
|
|
17
|
+
This module is runtime-only and is responsible for splitting the flat
|
|
18
|
+
`ctx.options` dict into strict options tiers (the user-facing CLI option
|
|
19
|
+
hierarchy). These tiers are separate from the storage stack
|
|
20
|
+
(StorageConfig / StorageDriverConfig / StorageBinding / StorageSession),
|
|
21
|
+
which handles I/O wiring independently.
|
|
22
|
+
|
|
23
|
+
Options tiers:
|
|
24
|
+
- EngineConfig (always present)
|
|
25
|
+
- TemplateConfig (optional, per template)
|
|
26
|
+
- PluginConfig (optional, per plugin)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import uuid
|
|
34
|
+
from enum import Enum, auto
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import click
|
|
38
|
+
|
|
39
|
+
from firecube.core.config import DATABASE_DUCKDB_KEYS
|
|
40
|
+
from firecube.core.controlplane import ChunkManager
|
|
41
|
+
from firecube.ingestor.config.engine import (
|
|
42
|
+
SYSTEM_KEYS,
|
|
43
|
+
EngineConfig,
|
|
44
|
+
config_keys,
|
|
45
|
+
is_experimental_option_key,
|
|
46
|
+
)
|
|
47
|
+
from firecube.ingestor.templates.config import TemplateConfig
|
|
48
|
+
from firecube.ingestor.types.config import PluginConfig
|
|
49
|
+
from firecube.ingestor.types.context import IngestContext
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ExecutionMode(Enum):
|
|
53
|
+
SEQUENTIAL = auto()
|
|
54
|
+
PIPELINE = auto()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TierConfigurator:
|
|
58
|
+
"""Splits a flat ``ctx.options`` dict into four strict options tiers.
|
|
59
|
+
|
|
60
|
+
These are the user-facing CLI option tiers, not the storage stack
|
|
61
|
+
(StorageConfig / StorageDriverConfig / StorageBinding). Storage I/O
|
|
62
|
+
wiring is handled separately by the storage layer.
|
|
63
|
+
|
|
64
|
+
Options tiers (in precedence order for key ownership):
|
|
65
|
+
EngineConfig — pipeline execution settings (workers, batch size,
|
|
66
|
+
write mode, workspace cleanup, …). Always present.
|
|
67
|
+
TemplateConfig — output-format settings (Zarr chunk shape, Parquet
|
|
68
|
+
partition config, …). Present only if the ingestor
|
|
69
|
+
declares ``template_config_class``.
|
|
70
|
+
DatabaseDuckDB — global ``[database.duckdb]`` defaults owned by core
|
|
71
|
+
(DuckDB resource settings shared across plugins).
|
|
72
|
+
PluginConfig — plugin-specific domain options (e.g. ``msg_region``,
|
|
73
|
+
``forecast_horizons``). Present only if the ingestor
|
|
74
|
+
declares ``plugin_config_class``.
|
|
75
|
+
|
|
76
|
+
Unknown keys (not owned by any tier) raise ``ValueError`` at configure
|
|
77
|
+
time, enforcing the strict schema contract.
|
|
78
|
+
|
|
79
|
+
``configure()`` is pure: it does not mutate ``ctx``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
template_config_class: type[TemplateConfig] | None,
|
|
85
|
+
plugin_config_class: type[PluginConfig] | None,
|
|
86
|
+
*,
|
|
87
|
+
plugin_name: str,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._template_config_class = template_config_class
|
|
90
|
+
self._plugin_config_class = plugin_config_class
|
|
91
|
+
self._plugin_name = plugin_name
|
|
92
|
+
|
|
93
|
+
def configure(
|
|
94
|
+
self, ctx: IngestContext
|
|
95
|
+
) -> tuple[EngineConfig, TemplateConfig | None, PluginConfig | None]:
|
|
96
|
+
options = ctx.options or {}
|
|
97
|
+
|
|
98
|
+
if "output_name" in options:
|
|
99
|
+
raise click.UsageError(
|
|
100
|
+
"Config key 'output_name' is no longer accepted. "
|
|
101
|
+
"Use '--product-name' or 'default_product_name' instead.\n"
|
|
102
|
+
"Edit the CLI invocation or the config file passed via --config-file."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
engine_keys = config_keys(EngineConfig)
|
|
106
|
+
template_keys = (
|
|
107
|
+
config_keys(self._template_config_class) if self._template_config_class else set()
|
|
108
|
+
)
|
|
109
|
+
plugin_keys = config_keys(self._plugin_config_class) if self._plugin_config_class else set()
|
|
110
|
+
duckdb_keys = set(DATABASE_DUCKDB_KEYS)
|
|
111
|
+
|
|
112
|
+
known = engine_keys | template_keys | duckdb_keys | plugin_keys | set(SYSTEM_KEYS)
|
|
113
|
+
unknown = {
|
|
114
|
+
key for key in options if key not in known and not is_experimental_option_key(key)
|
|
115
|
+
}
|
|
116
|
+
if unknown:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Unknown configuration options for {self._plugin_name}: "
|
|
119
|
+
f"{', '.join(sorted(unknown))}. Use --show-options to see valid keys. "
|
|
120
|
+
f"Experimental options must use the x_ prefix (e.g. x_foo=...)."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
engine_opts = {k: options[k] for k in engine_keys if k in options}
|
|
124
|
+
template_opts = {k: options[k] for k in template_keys if k in options}
|
|
125
|
+
plugin_opts = {k: options[k] for k in plugin_keys if k in options}
|
|
126
|
+
|
|
127
|
+
engine_config = EngineConfig.from_options(engine_opts)
|
|
128
|
+
template_config = (
|
|
129
|
+
self._template_config_class.from_options(template_opts)
|
|
130
|
+
if self._template_config_class
|
|
131
|
+
else None
|
|
132
|
+
)
|
|
133
|
+
plugin_config = (
|
|
134
|
+
self._plugin_config_class.from_options(plugin_opts)
|
|
135
|
+
if self._plugin_config_class
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return engine_config, template_config, plugin_config
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def determine_execution_mode(engine_config: EngineConfig) -> ExecutionMode:
|
|
143
|
+
pipeline_requested = (
|
|
144
|
+
bool(engine_config.pipeline_parallel) or int(engine_config.pipeline_workers) > 1
|
|
145
|
+
)
|
|
146
|
+
if pipeline_requested:
|
|
147
|
+
return ExecutionMode.PIPELINE
|
|
148
|
+
return ExecutionMode.SEQUENTIAL
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def ensure_run_id(*, ctx: IngestContext, plugin_name: str) -> str:
|
|
152
|
+
"""Resolve run_id from context/options or generate a new one (pure)."""
|
|
153
|
+
if ctx.run_id:
|
|
154
|
+
return ctx.run_id
|
|
155
|
+
|
|
156
|
+
existing = ctx.option("run_id") or ctx.option("manifest_run_id")
|
|
157
|
+
if existing:
|
|
158
|
+
return str(existing)
|
|
159
|
+
|
|
160
|
+
hostname = os.getenv("HOSTNAME", "host")
|
|
161
|
+
return f"{plugin_name}-{hostname}-{uuid.uuid4().hex}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_storage_driver(storage_driver: Any, *, storage_type: str) -> None:
|
|
165
|
+
driver = getattr(storage_driver, "driver", "fsspec")
|
|
166
|
+
if driver == "obstore":
|
|
167
|
+
from firecube.core.filesystem._obstore_compat import require_obstore
|
|
168
|
+
|
|
169
|
+
require_obstore()
|
|
170
|
+
if storage_type not in ("local", "s3"):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Storage driver 'obstore' supports 'local' and 's3' storage types, "
|
|
173
|
+
f"got '{storage_type}'. Use --storage-driver fsspec instead."
|
|
174
|
+
)
|
|
175
|
+
log = logging.getLogger(__name__)
|
|
176
|
+
log.info("Using obstore storage driver for all I/O (experimental)")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _storage_type_for_product_uri(product_uri: Any) -> str:
|
|
180
|
+
if product_uri.is_remote():
|
|
181
|
+
return "s3"
|
|
182
|
+
return "local"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def ensure_chunk_manager_config(chunk_manager: ChunkManager, ctx: IngestContext) -> None:
|
|
186
|
+
"""Configure ChunkManager from the ingestion context."""
|
|
187
|
+
if ctx.storage is not None and ctx.storage.output is not None:
|
|
188
|
+
product_uri = ctx.storage.output.product.product_uri
|
|
189
|
+
storage_type = _storage_type_for_product_uri(product_uri)
|
|
190
|
+
_validate_storage_driver(ctx.storage.output.driver, storage_type=storage_type)
|
|
191
|
+
|
|
192
|
+
if not getattr(chunk_manager, "base_uri", None):
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
"ChunkManager requires an output base URI. Provide a target or storage configuration."
|
|
195
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
"""Per-group write coverage tracking for span recording.
|
|
16
|
+
|
|
17
|
+
Extracted from the MTG FCI plugin as a generic runtime utility.
|
|
18
|
+
Accumulates per-group writes, tracks time bounds, merges contiguous
|
|
19
|
+
index ranges, and produces :class:`SpanCoverage` objects compatible
|
|
20
|
+
with :meth:`SpanRecorder.record_batch_success`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from firecube.core.controlplane.types import SpanCoverage
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CoverageTracker:
|
|
33
|
+
"""Collect per-group write coverage entries for span recording.
|
|
34
|
+
|
|
35
|
+
Coverage entries emitted by :meth:`build_coverage` follow the schema
|
|
36
|
+
expected by ingestion runtime span extraction.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, time_dim_name: str = "timestamp") -> None:
|
|
40
|
+
"""Initialize empty per-group coverage state."""
|
|
41
|
+
self._time_dim_name = time_dim_name
|
|
42
|
+
self._groups: dict[str, dict[str, Any]] = {}
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _time_to_iso8601(time_val: Any) -> str | None:
|
|
46
|
+
"""Convert supported timestamp-like values to ISO-8601.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
time_val: Timestamp-like value such as ``datetime``,
|
|
50
|
+
``np.datetime64``, or an ISO string.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
ISO-8601 string when conversion is possible, otherwise ``None``.
|
|
54
|
+
"""
|
|
55
|
+
if time_val is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if isinstance(time_val, str):
|
|
59
|
+
return time_val
|
|
60
|
+
|
|
61
|
+
if hasattr(time_val, "isoformat"):
|
|
62
|
+
try:
|
|
63
|
+
return str(time_val.isoformat())
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
if isinstance(time_val, np.datetime64):
|
|
68
|
+
text = np.datetime_as_string(time_val, timezone="UTC")
|
|
69
|
+
return text if text != "NaT" else None
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _merge_index_ranges(indices: set[int]) -> list[list[int]]:
|
|
75
|
+
"""Merge timestamp indices into inclusive contiguous ranges.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
indices: Set of written timestamp indices.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Inclusive ``[start, end]`` ranges sorted by start.
|
|
82
|
+
"""
|
|
83
|
+
if not indices:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
sorted_indices = sorted(indices)
|
|
87
|
+
ranges: list[list[int]] = []
|
|
88
|
+
start = sorted_indices[0]
|
|
89
|
+
end = sorted_indices[0]
|
|
90
|
+
|
|
91
|
+
for idx in sorted_indices[1:]:
|
|
92
|
+
if idx == end + 1:
|
|
93
|
+
end = idx
|
|
94
|
+
continue
|
|
95
|
+
ranges.append([start, end])
|
|
96
|
+
start = idx
|
|
97
|
+
end = idx
|
|
98
|
+
|
|
99
|
+
ranges.append([start, end])
|
|
100
|
+
return ranges
|
|
101
|
+
|
|
102
|
+
def record_write(
|
|
103
|
+
self,
|
|
104
|
+
group: str,
|
|
105
|
+
arrays: list[str],
|
|
106
|
+
ts_index: int,
|
|
107
|
+
time_val: Any,
|
|
108
|
+
aligned: bool,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Record one write operation for coverage accumulation.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
group: Zarr group written by the operation.
|
|
114
|
+
arrays: Array paths touched by the write.
|
|
115
|
+
ts_index: Timestamp index written in this operation.
|
|
116
|
+
time_val: Timestamp value corresponding to ``ts_index``.
|
|
117
|
+
aligned: Whether this write was chunk-aligned.
|
|
118
|
+
"""
|
|
119
|
+
entry = self._groups.setdefault(
|
|
120
|
+
group,
|
|
121
|
+
{
|
|
122
|
+
"arrays": set(),
|
|
123
|
+
"indices": set(),
|
|
124
|
+
"aligned": True,
|
|
125
|
+
"time_min": None,
|
|
126
|
+
"time_max": None,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
entry["arrays"].update(arrays)
|
|
131
|
+
entry["indices"].add(int(ts_index))
|
|
132
|
+
entry["aligned"] = bool(entry["aligned"]) and bool(aligned)
|
|
133
|
+
|
|
134
|
+
iso_time = self._time_to_iso8601(time_val)
|
|
135
|
+
if iso_time is None:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
current_min = entry["time_min"]
|
|
139
|
+
current_max = entry["time_max"]
|
|
140
|
+
if current_min is None or iso_time < current_min:
|
|
141
|
+
entry["time_min"] = iso_time
|
|
142
|
+
if current_max is None or iso_time > current_max:
|
|
143
|
+
entry["time_max"] = iso_time
|
|
144
|
+
|
|
145
|
+
def build_coverage(
|
|
146
|
+
self,
|
|
147
|
+
state_array_name: str = "firecube_timestamp_state",
|
|
148
|
+
time_dim_name: str | None = None,
|
|
149
|
+
) -> list[SpanCoverage]:
|
|
150
|
+
"""Build span-coverage entries for all recorded groups.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
state_array_name: Timestamp-state variable name under each group.
|
|
154
|
+
time_dim_name: Time dimension name to record on each coverage
|
|
155
|
+
entry; falls back to the tracker's constructor value.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of :class:`SpanCoverage` objects, one per recorded group.
|
|
159
|
+
"""
|
|
160
|
+
effective_dim = time_dim_name or self._time_dim_name
|
|
161
|
+
coverage: list[SpanCoverage] = []
|
|
162
|
+
for group in sorted(self._groups):
|
|
163
|
+
entry = self._groups[group]
|
|
164
|
+
coverage.append(
|
|
165
|
+
SpanCoverage(
|
|
166
|
+
group=group,
|
|
167
|
+
arrays=sorted(entry["arrays"]),
|
|
168
|
+
time_index_ranges=self._merge_index_ranges(entry["indices"]),
|
|
169
|
+
aligned=bool(entry["aligned"]),
|
|
170
|
+
state_array=f"{group}/{state_array_name}",
|
|
171
|
+
state_deleted_value=2,
|
|
172
|
+
time_min=entry["time_min"],
|
|
173
|
+
time_max=entry["time_max"],
|
|
174
|
+
time_dim_name=effective_dim,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return coverage
|