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,89 @@
|
|
|
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
|
+
"""Configuration infrastructure for Firecube plugins.
|
|
16
|
+
|
|
17
|
+
This module provides the `PluginConfig` base class, which enables strict options validation,
|
|
18
|
+
type conversion, and help generation for plugins.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, fields
|
|
24
|
+
from typing import Any, ClassVar, TypeVar, get_type_hints
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T", bound="PluginConfig")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PluginConfig:
|
|
31
|
+
"""Base configuration for all Firecube ingestors.
|
|
32
|
+
|
|
33
|
+
Subclasses should define fields as dataclass fields.
|
|
34
|
+
Use `from_options` to parse and validate a raw dictionary.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
_allow_unknown: Class-level escape hatch for plugins that intentionally
|
|
38
|
+
accept unknown option keys. Keep this ``False`` for strict validation.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Common options for all ingestors
|
|
42
|
+
# Engine fields have been moved to EngineConfig.
|
|
43
|
+
# PluginConfig should only contain user-defined domain logic fields.
|
|
44
|
+
|
|
45
|
+
# If the subclass wants to allow unknown keys, set this to True.
|
|
46
|
+
# Generally false for strictness.
|
|
47
|
+
_allow_unknown: ClassVar[bool] = False
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_options(cls: type[T], options: dict[str, Any]) -> T:
|
|
51
|
+
"""Create a config instance from a dictionary, with strict validation.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
options: Dictionary of options (e.g. from CLI or config file).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
An instance of the config class.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If unknown keys are present (and _allow_unknown is False)
|
|
61
|
+
or if type conversion fails.
|
|
62
|
+
"""
|
|
63
|
+
known_fields = {f.name: f for f in fields(cls)}
|
|
64
|
+
known_keys = set(known_fields.keys())
|
|
65
|
+
input_keys = set(options.keys())
|
|
66
|
+
|
|
67
|
+
# 1. Unknown Key Check
|
|
68
|
+
if not cls._allow_unknown:
|
|
69
|
+
unknown = input_keys - known_keys
|
|
70
|
+
if unknown:
|
|
71
|
+
# Filter out likely system keys that might be implicitly passed?
|
|
72
|
+
# For now, strict means strict.
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Unknown configuration keys for {cls.__name__}: {', '.join(sorted(unknown))}. "
|
|
75
|
+
f"Valid keys: {', '.join(sorted(known_keys))}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# 2. Type Conversion / Construction
|
|
79
|
+
# We only pass known keys to the constructor
|
|
80
|
+
from firecube.ingestor.config.coercion import coerce_cli_value
|
|
81
|
+
|
|
82
|
+
type_hints = get_type_hints(cls)
|
|
83
|
+
init_kwargs = {}
|
|
84
|
+
for key, raw_value in options.items():
|
|
85
|
+
if key not in known_keys:
|
|
86
|
+
continue
|
|
87
|
+
target_type = type_hints.get(key)
|
|
88
|
+
init_kwargs[key] = coerce_cli_value(raw_value, target_type, key)
|
|
89
|
+
return cls(**init_kwargs)
|
|
@@ -0,0 +1,409 @@
|
|
|
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
|
+
"""Shared datatypes for ingestion plugins and runners."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from .result_metrics import ( # pyright: ignore[reportMissingImports]
|
|
25
|
+
OutputPaths,
|
|
26
|
+
ResultMetrics,
|
|
27
|
+
_coerce_output_paths,
|
|
28
|
+
_coerce_result_metrics,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from firecube.core.storage import StorageWriteResult
|
|
33
|
+
from firecube.core.storage.session import StorageSession
|
|
34
|
+
|
|
35
|
+
# Avoid circular import at runtime
|
|
36
|
+
from firecube.ingestor.contracts.interfaces import IngestionTelemetry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Internal constants
|
|
40
|
+
BATCH_META_TIMESTAMPS = "timestamps"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class StorageContext:
|
|
45
|
+
"""Named storage role bindings for an ingestion run."""
|
|
46
|
+
|
|
47
|
+
output: StorageSession | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class IngestContext:
|
|
52
|
+
"""Caller-provided input context for one ingestion run."""
|
|
53
|
+
|
|
54
|
+
source: str
|
|
55
|
+
target: str | None = None
|
|
56
|
+
in_memory: bool = True
|
|
57
|
+
output_format: str | None = None
|
|
58
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
storage: StorageContext | None = None
|
|
60
|
+
|
|
61
|
+
# Engine-injected runtime configuration (READ-ONLY for plugins)
|
|
62
|
+
run_id: str | None = None
|
|
63
|
+
|
|
64
|
+
# Core-injected telemetry sink (metrics + tracing). Plugins should only call
|
|
65
|
+
# this object; they must not import Prometheus/OTel directly.
|
|
66
|
+
telemetry: IngestionTelemetry | None = None
|
|
67
|
+
|
|
68
|
+
def option(self, key: str, default: Any = None) -> Any:
|
|
69
|
+
"""Convenience accessor for optional plugin parameters."""
|
|
70
|
+
return self.options.get(key, default)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class RuntimeIdentity:
|
|
75
|
+
"""Run identity and workspace location for one execution."""
|
|
76
|
+
|
|
77
|
+
run_id: str
|
|
78
|
+
temp_root: Path | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class RuntimeFlags:
|
|
83
|
+
"""Engine-owned execution flags for one run."""
|
|
84
|
+
|
|
85
|
+
force_reingest: bool = False
|
|
86
|
+
incremental: bool = False
|
|
87
|
+
dry_run: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class RuntimeServices:
|
|
92
|
+
"""Internal service handles used by runtime-only context behavior."""
|
|
93
|
+
|
|
94
|
+
materializer: Any | None = field(default=None, repr=False, compare=False)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class RuntimeIngestContext(IngestContext):
|
|
99
|
+
"""Engine-owned runtime context copied from ``IngestContext``.
|
|
100
|
+
|
|
101
|
+
This carries internal execution-only state and must never be reused across runs.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
identity: RuntimeIdentity = field(default_factory=lambda: RuntimeIdentity(run_id=""))
|
|
105
|
+
flags: RuntimeFlags = field(default_factory=RuntimeFlags)
|
|
106
|
+
services: RuntimeServices = field(default_factory=RuntimeServices)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_ingest_context(
|
|
110
|
+
cls,
|
|
111
|
+
ctx: IngestContext,
|
|
112
|
+
*,
|
|
113
|
+
run_id: str,
|
|
114
|
+
temp_root: Path | None,
|
|
115
|
+
materializer: Any,
|
|
116
|
+
) -> RuntimeIngestContext:
|
|
117
|
+
"""Build an isolated runtime copy without mutating the caller context."""
|
|
118
|
+
options = dict(ctx.options or {})
|
|
119
|
+
options.setdefault("run_id", run_id)
|
|
120
|
+
return cls(
|
|
121
|
+
source=ctx.source,
|
|
122
|
+
target=ctx.target,
|
|
123
|
+
in_memory=ctx.in_memory,
|
|
124
|
+
output_format=ctx.output_format,
|
|
125
|
+
options=options,
|
|
126
|
+
storage=ctx.storage,
|
|
127
|
+
run_id=run_id,
|
|
128
|
+
telemetry=ctx.telemetry,
|
|
129
|
+
identity=RuntimeIdentity(run_id=run_id, temp_root=temp_root),
|
|
130
|
+
services=RuntimeServices(materializer=materializer),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def temp_root(self) -> Path | None:
|
|
135
|
+
"""Per-run workspace root used for materialization and temporary files."""
|
|
136
|
+
return self.identity.temp_root
|
|
137
|
+
|
|
138
|
+
@temp_root.setter
|
|
139
|
+
def temp_root(self, value: Path | None) -> None:
|
|
140
|
+
"""Update the per-run workspace root for this runtime context."""
|
|
141
|
+
self.identity.temp_root = value
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def force_reingest(self) -> bool:
|
|
145
|
+
"""Whether this run is allowed to overwrite existing slice data."""
|
|
146
|
+
return self.flags.force_reingest
|
|
147
|
+
|
|
148
|
+
@force_reingest.setter
|
|
149
|
+
def force_reingest(self, value: bool) -> None:
|
|
150
|
+
"""Set overwrite behavior for this runtime execution."""
|
|
151
|
+
self.flags.force_reingest = bool(value)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def incremental(self) -> bool:
|
|
155
|
+
"""Whether plugin logic should prefer incremental update behavior."""
|
|
156
|
+
return self.flags.incremental
|
|
157
|
+
|
|
158
|
+
@incremental.setter
|
|
159
|
+
def incremental(self, value: bool) -> None:
|
|
160
|
+
"""Set incremental processing preference for this runtime execution."""
|
|
161
|
+
self.flags.incremental = bool(value)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def dry_run(self) -> bool:
|
|
165
|
+
"""Whether side-effecting writes should be suppressed when supported."""
|
|
166
|
+
return self.flags.dry_run
|
|
167
|
+
|
|
168
|
+
@dry_run.setter
|
|
169
|
+
def dry_run(self, value: bool) -> None:
|
|
170
|
+
"""Set dry-run behavior for this runtime execution."""
|
|
171
|
+
self.flags.dry_run = bool(value)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def _materializer(self) -> Any | None:
|
|
175
|
+
return self.services.materializer
|
|
176
|
+
|
|
177
|
+
@_materializer.setter
|
|
178
|
+
def _materializer(self, value: Any | None) -> None:
|
|
179
|
+
self.services.materializer = value
|
|
180
|
+
|
|
181
|
+
def materialize(self, source: Any) -> Path:
|
|
182
|
+
"""Ensure the source file is available locally and return its path.
|
|
183
|
+
|
|
184
|
+
If the file is remote (e.g. S3), it will be downloaded to the per-run cache.
|
|
185
|
+
If it's already local, the path is returned directly.
|
|
186
|
+
"""
|
|
187
|
+
if self.services.materializer:
|
|
188
|
+
return self.services.materializer(source)
|
|
189
|
+
|
|
190
|
+
# Fallback for local files if no materializer configured
|
|
191
|
+
if isinstance(source, (str, Path)):
|
|
192
|
+
p = Path(source).resolve() # firecube: STORAGE-URI
|
|
193
|
+
if p.exists():
|
|
194
|
+
return p
|
|
195
|
+
|
|
196
|
+
# Fallback for SourceFile protocol
|
|
197
|
+
local_path = getattr(source, "local_path", None)
|
|
198
|
+
if callable(local_path):
|
|
199
|
+
lp = local_path()
|
|
200
|
+
if isinstance(lp, Path):
|
|
201
|
+
return lp
|
|
202
|
+
if isinstance(lp, str):
|
|
203
|
+
return Path(lp).resolve()
|
|
204
|
+
|
|
205
|
+
raise RuntimeError("MaterializationContext not configured: cannot materialize source.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class PluginContext:
|
|
209
|
+
"""Read-only proxy for IngestContext for all plugin-facing hooks."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, ctx: RuntimeIngestContext):
|
|
212
|
+
self._ctx = ctx
|
|
213
|
+
raw_options = getattr(ctx, "options", {}) or {}
|
|
214
|
+
if not isinstance(raw_options, dict):
|
|
215
|
+
try:
|
|
216
|
+
raw_options = dict(raw_options)
|
|
217
|
+
except Exception:
|
|
218
|
+
raw_options = {}
|
|
219
|
+
self._options = MappingProxyType(dict(raw_options))
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def source(self) -> str:
|
|
223
|
+
return self._ctx.source
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def target(self) -> str | None:
|
|
227
|
+
return self._ctx.target
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def in_memory(self) -> bool:
|
|
231
|
+
return self._ctx.in_memory
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def output_format(self) -> str | None:
|
|
235
|
+
return self._ctx.output_format
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def storage(self) -> StorageContext | None:
|
|
239
|
+
"""Storage sessions bound to this plugin run, keyed by role."""
|
|
240
|
+
return self._ctx.storage
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def temp_root(self) -> Path | None:
|
|
244
|
+
return self._ctx.temp_root
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def force_reingest(self) -> bool:
|
|
248
|
+
return self._ctx.force_reingest
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def incremental(self) -> bool:
|
|
252
|
+
return self._ctx.incremental
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def dry_run(self) -> bool:
|
|
256
|
+
return self._ctx.dry_run
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def telemetry(self) -> IngestionTelemetry | None:
|
|
260
|
+
return self._ctx.telemetry
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def options(self) -> dict[str, Any]:
|
|
264
|
+
return self._options # type: ignore[return-value]
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def run_id(self) -> str | None:
|
|
268
|
+
"""Stable run identifier assigned by the engine for this execution."""
|
|
269
|
+
return self._ctx.run_id
|
|
270
|
+
|
|
271
|
+
def option(self, key: str, default: Any = None) -> Any:
|
|
272
|
+
return self._options.get(key, default)
|
|
273
|
+
|
|
274
|
+
def materialize(self, source: Any) -> Path:
|
|
275
|
+
return self._ctx.materialize(source)
|
|
276
|
+
|
|
277
|
+
def __getattr__(self, name: str) -> Any:
|
|
278
|
+
# PluginContext is intentionally narrow: hooks must only rely on the
|
|
279
|
+
# explicitly exposed read-only API above.
|
|
280
|
+
if name in ("_chunk_manager", "_materializer"):
|
|
281
|
+
raise AttributeError(f"Access to {name} is forbidden in PluginContext")
|
|
282
|
+
raise AttributeError(f"{name} is not available on PluginContext")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(slots=True, init=False)
|
|
286
|
+
class IngestResult:
|
|
287
|
+
"""Structured dataset returned by plugins."""
|
|
288
|
+
|
|
289
|
+
output_format: str
|
|
290
|
+
outputs: OutputPaths = field(default_factory=OutputPaths)
|
|
291
|
+
metrics: ResultMetrics = field(default_factory=ResultMetrics)
|
|
292
|
+
registered: bool = False
|
|
293
|
+
spans_recorded: bool = False
|
|
294
|
+
storage_result: StorageWriteResult | None = None
|
|
295
|
+
write_mode_applied: str | None = None
|
|
296
|
+
manifest: dict | None = None
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
output_format: str,
|
|
302
|
+
outputs: OutputPaths | None = None,
|
|
303
|
+
metrics: ResultMetrics | dict[str, Any] | None = None,
|
|
304
|
+
registered: bool = False,
|
|
305
|
+
spans_recorded: bool = False,
|
|
306
|
+
storage_result: StorageWriteResult | None = None,
|
|
307
|
+
write_mode_applied: str | None = None,
|
|
308
|
+
manifest: dict | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
self.output_format = output_format
|
|
311
|
+
self.outputs = _coerce_output_paths(outputs, output_format=output_format)
|
|
312
|
+
self.metrics = _coerce_result_metrics(metrics)
|
|
313
|
+
self.registered = registered
|
|
314
|
+
self.spans_recorded = spans_recorded
|
|
315
|
+
self.storage_result = storage_result
|
|
316
|
+
self.write_mode_applied = write_mode_applied
|
|
317
|
+
self.manifest = manifest
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def output_path(self) -> Path | str | None:
|
|
321
|
+
"""Compatibility view of the primary output path."""
|
|
322
|
+
return self.outputs.primary
|
|
323
|
+
|
|
324
|
+
def all_outputs(self) -> OutputPaths:
|
|
325
|
+
"""Return the typed outputs container for compatibility callers."""
|
|
326
|
+
return self.outputs
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass(slots=True)
|
|
330
|
+
class PipelineBatch:
|
|
331
|
+
"""A batch of data ready for processing in the pipeline."""
|
|
332
|
+
|
|
333
|
+
batch_id: str
|
|
334
|
+
data_path: Path
|
|
335
|
+
items: list[Any] = field(default_factory=list)
|
|
336
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
337
|
+
size_bytes: int = 0
|
|
338
|
+
files_count: int = 0
|
|
339
|
+
groups: list[str] = field(
|
|
340
|
+
default_factory=list
|
|
341
|
+
) # Logical groups (e.g. horizons) covered by this batch
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass(slots=True, init=False)
|
|
345
|
+
class PipelineResult:
|
|
346
|
+
"""Result from a pipeline batch processing."""
|
|
347
|
+
|
|
348
|
+
batch: PipelineBatch
|
|
349
|
+
output_format: str = "zarr"
|
|
350
|
+
outputs: OutputPaths = field(default_factory=OutputPaths)
|
|
351
|
+
duration_s: float = 0.0
|
|
352
|
+
cpu_time_s: float = 0.0
|
|
353
|
+
io_time_s: float = 0.0
|
|
354
|
+
metrics: ResultMetrics = field(default_factory=ResultMetrics)
|
|
355
|
+
success: bool = True
|
|
356
|
+
error: str | None = None
|
|
357
|
+
|
|
358
|
+
def __init__(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
batch: PipelineBatch,
|
|
362
|
+
outputs: OutputPaths | None = None,
|
|
363
|
+
duration_s: float = 0.0,
|
|
364
|
+
cpu_time_s: float = 0.0,
|
|
365
|
+
io_time_s: float = 0.0,
|
|
366
|
+
metrics: ResultMetrics | dict[str, Any] | None = None,
|
|
367
|
+
success: bool = True,
|
|
368
|
+
error: str | None = None,
|
|
369
|
+
output_format: str = "zarr",
|
|
370
|
+
) -> None:
|
|
371
|
+
self.batch = batch
|
|
372
|
+
self.output_format = output_format
|
|
373
|
+
self.outputs = _coerce_output_paths(outputs, output_format=output_format)
|
|
374
|
+
self.duration_s = duration_s
|
|
375
|
+
self.cpu_time_s = cpu_time_s
|
|
376
|
+
self.io_time_s = io_time_s
|
|
377
|
+
self.metrics = _coerce_result_metrics(metrics)
|
|
378
|
+
self.success = success
|
|
379
|
+
self.error = error
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def output_path(self) -> Path | str | None:
|
|
383
|
+
"""Compatibility view of the primary output path."""
|
|
384
|
+
return self.outputs.primary
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(slots=True, frozen=True)
|
|
388
|
+
class PipelineRunState:
|
|
389
|
+
"""Immutable run snapshot for pipeline orchestration and hooks."""
|
|
390
|
+
|
|
391
|
+
product: str
|
|
392
|
+
pipeline_workers: int
|
|
393
|
+
batch_size: int
|
|
394
|
+
batches: tuple[PipelineBatch, ...]
|
|
395
|
+
ingestion_start_time: float
|
|
396
|
+
batch_creation_duration: float
|
|
397
|
+
processing_start_time: float
|
|
398
|
+
processing_duration: float = 0.0
|
|
399
|
+
total_ingestion_duration: float = 0.0
|
|
400
|
+
results: tuple[PipelineResult, ...] = field(default_factory=tuple)
|
|
401
|
+
total_metrics: dict[str, Any] = field(default_factory=dict)
|
|
402
|
+
total_rows_processed: int = 0
|
|
403
|
+
hook_failures: int = 0
|
|
404
|
+
cpu_time_total: float = 0.0
|
|
405
|
+
io_time_total: float = 0.0
|
|
406
|
+
|
|
407
|
+
def __post_init__(self) -> None:
|
|
408
|
+
object.__setattr__(self, "batches", tuple(self.batches))
|
|
409
|
+
object.__setattr__(self, "results", tuple(self.results))
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
"""Strict schema for ingestion result manifests.
|
|
16
|
+
|
|
17
|
+
This module defines the contract for the final JSON output of the CLI,
|
|
18
|
+
ensuring downstream consumers (e.g. Airflow) have a stable API.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class IngestManifest:
|
|
29
|
+
"""The public contract for ingestion results."""
|
|
30
|
+
|
|
31
|
+
# Core Identity
|
|
32
|
+
plugin: str
|
|
33
|
+
output_format: str
|
|
34
|
+
|
|
35
|
+
# Storage Results
|
|
36
|
+
stored_at: str
|
|
37
|
+
files: int
|
|
38
|
+
bytes: int
|
|
39
|
+
duration_s: float
|
|
40
|
+
|
|
41
|
+
# Schema Version (Must be after non-default fields)
|
|
42
|
+
schema_version: Literal["v1"] = "v1"
|
|
43
|
+
|
|
44
|
+
# Detailed Metrics (Pipeline Stats)
|
|
45
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
# Extended Metadata (Optional but recommended)
|
|
48
|
+
run_id: str | None = None
|
|
49
|
+
product: str | None = None
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Convert to dictionary for JSON serialization, excluding None values."""
|
|
53
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
"""Slot-range types and helpers for parallel ingestion (Phase 3)."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from firecube.ingestor.errors import ConfigurationError
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from firecube.core.controlplane.types import SpanCoverage
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Half-open range type alias: (slot_start, slot_end) where slot_start is inclusive, slot_end exclusive.
|
|
31
|
+
SlotRange = tuple[int, int]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PlannedRange:
|
|
36
|
+
"""A half-open slot range [slot_start, slot_end) for one Zarr group.
|
|
37
|
+
|
|
38
|
+
Uses HALF-OPEN semantics: slot_start is inclusive, slot_end is exclusive.
|
|
39
|
+
This matches Python slicing convention and engine array indexing.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
group: str
|
|
43
|
+
slot_start: int
|
|
44
|
+
slot_end: int
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
validate_slot_range(self.slot_start, self.slot_end)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_slot_range(slot_start: int, slot_end: int) -> None:
|
|
51
|
+
"""Validate that ``[slot_start, slot_end)`` is well-formed and non-negative."""
|
|
52
|
+
if slot_start < 0:
|
|
53
|
+
raise ValueError(f"slot_start must be >= 0, got {slot_start}")
|
|
54
|
+
if slot_end < 0:
|
|
55
|
+
raise ValueError(f"slot_end must be >= 0, got {slot_end}")
|
|
56
|
+
if slot_start >= slot_end:
|
|
57
|
+
raise ValueError(f"slot_start must be < slot_end, got [{slot_start}, {slot_end})")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compute_covered_ranges(coverage: list[SpanCoverage]) -> list[PlannedRange]:
|
|
61
|
+
"""Convert inclusive coverage ranges into half-open ``PlannedRange`` values."""
|
|
62
|
+
ranges: list[PlannedRange] = []
|
|
63
|
+
for span in coverage:
|
|
64
|
+
if not span.time_index_ranges:
|
|
65
|
+
continue
|
|
66
|
+
for slot_start, slot_end in span.time_index_ranges:
|
|
67
|
+
ranges.append(
|
|
68
|
+
PlannedRange(group=span.group, slot_start=slot_start, slot_end=slot_end + 1)
|
|
69
|
+
)
|
|
70
|
+
return ranges
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def chunk_align_ranges(ranges: list[PlannedRange], chunk_size: int) -> list[PlannedRange]:
|
|
74
|
+
"""Expand ranges to chunk boundaries, then deduplicate and sort them."""
|
|
75
|
+
if chunk_size <= 0:
|
|
76
|
+
raise ValueError(f"chunk_size must be > 0, got {chunk_size}")
|
|
77
|
+
|
|
78
|
+
aligned: dict[tuple[str, int, int], PlannedRange] = {}
|
|
79
|
+
for range_ in ranges:
|
|
80
|
+
aligned_start = (range_.slot_start // chunk_size) * chunk_size
|
|
81
|
+
aligned_end = math.ceil(range_.slot_end / chunk_size) * chunk_size
|
|
82
|
+
aligned_range = PlannedRange(
|
|
83
|
+
group=range_.group, slot_start=aligned_start, slot_end=aligned_end
|
|
84
|
+
)
|
|
85
|
+
aligned[(aligned_range.group, aligned_range.slot_start, aligned_range.slot_end)] = (
|
|
86
|
+
aligned_range
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return sorted(aligned.values(), key=lambda r: (r.group, r.slot_start, r.slot_end))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _chunk_alignment_message(
|
|
93
|
+
slot_start: int,
|
|
94
|
+
slot_end: int,
|
|
95
|
+
group: str,
|
|
96
|
+
chunk_shapes: list[tuple[int, ...]],
|
|
97
|
+
) -> str:
|
|
98
|
+
chunk_sizes = [shape[0] for shape in chunk_shapes]
|
|
99
|
+
alignment = math.lcm(*chunk_sizes)
|
|
100
|
+
aligned_start = (slot_start // alignment) * alignment
|
|
101
|
+
aligned_end = math.ceil(slot_end / alignment) * alignment
|
|
102
|
+
return (
|
|
103
|
+
f"Group '{group}' chunk_shapes={chunk_shapes}; slot range [{slot_start}, {slot_end}) misaligned. "
|
|
104
|
+
f"Suggested aligned range: [{aligned_start}, {aligned_end})"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_chunk_alignment(
|
|
109
|
+
slot_start: int,
|
|
110
|
+
slot_end: int,
|
|
111
|
+
chunk_shapes_per_group: dict[str, list[tuple[int, ...]]],
|
|
112
|
+
global_expected: dict[str, int] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Raise ``ConfigurationError`` when any group's slot range is misaligned.
|
|
115
|
+
|
|
116
|
+
Terminal partial chunks are allowed when ``slot_end`` matches the group's
|
|
117
|
+
global expected total.
|
|
118
|
+
"""
|
|
119
|
+
for group, chunk_shapes in chunk_shapes_per_group.items():
|
|
120
|
+
group_total = (global_expected or {}).get(group)
|
|
121
|
+
misaligned_shapes = [
|
|
122
|
+
shape
|
|
123
|
+
for shape in chunk_shapes
|
|
124
|
+
if slot_start % shape[0] != 0 or (slot_end % shape[0] != 0 and slot_end != group_total)
|
|
125
|
+
]
|
|
126
|
+
if misaligned_shapes:
|
|
127
|
+
raise ConfigurationError(
|
|
128
|
+
_chunk_alignment_message(slot_start, slot_end, group, misaligned_shapes)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def warn_if_misaligned(
|
|
133
|
+
slot_start: int,
|
|
134
|
+
slot_end: int,
|
|
135
|
+
chunk_shapes_per_group: dict[str, list[tuple[int, ...]]],
|
|
136
|
+
logger: logging.Logger,
|
|
137
|
+
global_expected: dict[str, int] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Log a warning for each group whose slot range is not chunk-aligned.
|
|
140
|
+
|
|
141
|
+
Terminal partial chunks are allowed when ``slot_end`` matches the group's
|
|
142
|
+
global expected total.
|
|
143
|
+
"""
|
|
144
|
+
for group, chunk_shapes in chunk_shapes_per_group.items():
|
|
145
|
+
group_total = (global_expected or {}).get(group)
|
|
146
|
+
misaligned_shapes = [
|
|
147
|
+
shape
|
|
148
|
+
for shape in chunk_shapes
|
|
149
|
+
if slot_start % shape[0] != 0 or (slot_end % shape[0] != 0 and slot_end != group_total)
|
|
150
|
+
]
|
|
151
|
+
if misaligned_shapes:
|
|
152
|
+
logger.warning(_chunk_alignment_message(slot_start, slot_end, group, misaligned_shapes))
|