firecube 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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