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,144 @@
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
+ """Plugin registry and discovery for Firecube ingestors.
16
+
17
+ Core responsibilities:
18
+ - Provide the @register_ingestor decorator for PipelinedIngestor subclasses.
19
+ - Keep an in-memory mapping of available ingestors by name.
20
+ - Discover built-in plugins under firecube.ingestor.*
21
+ - Discover external plugins via entry points (group: 'firecube.plugins').
22
+
23
+ Non-goals:
24
+
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ import pkgutil
31
+ from importlib.metadata import entry_points
32
+ from types import ModuleType
33
+ from typing import TypeVar
34
+
35
+ from firecube.ingestor.contracts.interfaces import Ingestor
36
+
37
+ AVAILABLE_INGESTORS: dict[str, type[Ingestor]] = {}
38
+ _LOADED: bool = False
39
+ _IngestorT = TypeVar("_IngestorT", bound=Ingestor)
40
+
41
+
42
+ def register_ingestor(name: str):
43
+ """Decorator to register a plugin under a given name."""
44
+
45
+ def decorator(cls: type[_IngestorT]) -> type[_IngestorT]:
46
+ # Using runtime protocol check
47
+ if not issubclass(cls, Ingestor):
48
+ raise TypeError("Ingestor must satisfy the Ingestor protocol")
49
+
50
+ AVAILABLE_INGESTORS[name] = cls
51
+ cls.name = name # pyright: ignore[reportAttributeAccessIssue]
52
+ return cls
53
+
54
+ return decorator
55
+
56
+
57
+ def get_ingestor(name: str) -> type[Ingestor]:
58
+ """Retrieve a registered ingestor class by name.
59
+
60
+ Args:
61
+ name: The name used in @register_ingestor.
62
+
63
+ Returns:
64
+ The registered Ingestor class.
65
+
66
+ Raises:
67
+ KeyError: If the ingestor is not found.
68
+ """
69
+
70
+ try:
71
+ return AVAILABLE_INGESTORS[name]
72
+ except KeyError as exc:
73
+ raise KeyError(f"Ingestor '{name}' not registered") from exc
74
+
75
+
76
+ def discover_ingestors() -> dict[str, type[Ingestor]]:
77
+ """Import all plugin modules so that decorators can register them.
78
+
79
+ Discovery strategy:
80
+ 1) If already loaded, return cached copy.
81
+ 2) Load entry points from group 'firecube.plugins'.
82
+ This triggers module imports, ensuring @register_ingestor runs.
83
+ 3) If FIRECUBE_LOAD_BUILTIN_PLUGINS=1 is set, fallback to walking
84
+ the `firecube.plugins` namespace.
85
+ """
86
+ global _LOADED
87
+ if _LOADED:
88
+ return AVAILABLE_INGESTORS.copy()
89
+
90
+ import os
91
+
92
+ # 1. Entry Points (Standard)
93
+ group = "firecube.plugins"
94
+ eps = entry_points(group=group)
95
+
96
+ from firecube.ingestor.registry.version_compat import warn_if_incompatible
97
+
98
+ for ep in eps:
99
+ try:
100
+ ep.load()
101
+ except Exception as e:
102
+ import logging
103
+
104
+ logging.getLogger("firecube.registry").warning(
105
+ f"Failed to load plugin entry point '{ep.name}': {e}"
106
+ )
107
+ continue
108
+
109
+ registered = AVAILABLE_INGESTORS.get(ep.name)
110
+ if registered is not None:
111
+ warn_if_incompatible(ep.name, registered)
112
+
113
+ # 2. Legacy Fallback (Dev-only)
114
+ if os.environ.get("FIRECUBE_LOAD_BUILTIN_PLUGINS") == "1":
115
+ try:
116
+ import firecube.plugins as plugins_pkg # pyright: ignore[reportMissingImports]
117
+ except ImportError:
118
+ plugins_pkg = None
119
+
120
+ def _import_submodules(package: ModuleType, prefix: str) -> None:
121
+ for mod_info in pkgutil.iter_modules(package.__path__):
122
+ full_name = f"{prefix}.{mod_info.name}"
123
+ try:
124
+ module = importlib.import_module(full_name)
125
+ if mod_info.ispkg:
126
+ _import_submodules(module, full_name)
127
+ except Exception:
128
+ pass
129
+
130
+ if plugins_pkg is not None:
131
+ _import_submodules(plugins_pkg, plugins_pkg.__name__)
132
+
133
+ _LOADED = True
134
+ return AVAILABLE_INGESTORS.copy()
135
+
136
+
137
+ def reset_plugin_discovery_cache() -> None:
138
+ """Invalidate the plugin discovery cache.
139
+
140
+ Used by tests and plugin management CLI commands to force re-discovery.
141
+ """
142
+ global _LOADED
143
+ AVAILABLE_INGESTORS.clear()
144
+ _LOADED = False
@@ -0,0 +1,97 @@
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
+ """
16
+ Shared helpers for surfacing plugin metadata to CLI and API layers.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ from firecube.ingestor.registry.loader import AVAILABLE_INGESTORS, discover_ingestors, get_ingestor
24
+
25
+
26
+ @dataclass
27
+ class PluginDescriptor:
28
+ name: str
29
+ module: str
30
+ description: str | None
31
+ products: dict[str, object]
32
+ defaults: dict[str, object] | None
33
+ required_options: dict[str, object] | None
34
+ product_name: str | None = None
35
+
36
+ def to_dict(self) -> dict[str, object]:
37
+ return {
38
+ "name": self.name,
39
+ "module": self.module,
40
+ "description": self.description,
41
+ "products": self.products,
42
+ "defaults": self.defaults,
43
+ "required_options": self.required_options,
44
+ "product_name": self.product_name,
45
+ }
46
+
47
+
48
+ def _ensure_loaded() -> None:
49
+ discover_ingestors()
50
+
51
+
52
+ def list_plugins() -> list[str]:
53
+ _ensure_loaded()
54
+ return sorted(AVAILABLE_INGESTORS.keys())
55
+
56
+
57
+ def get_plugin_descriptor(plugin: str) -> PluginDescriptor:
58
+ _ensure_loaded()
59
+ cls = get_ingestor(plugin)
60
+ description = (cls.__doc__ or "").strip() or None
61
+
62
+ products: dict[str, object] = {}
63
+ spec = getattr(cls, "spec", None)
64
+ if spec:
65
+ for attr in ("product", "variable", "file_glob"):
66
+ value = getattr(spec, attr, None)
67
+ if value:
68
+ products[attr] = value
69
+
70
+ defaults = None
71
+ if hasattr(cls, "plugin_defaults"):
72
+ defaults = cls.plugin_defaults() # type: ignore[attr-defined]
73
+
74
+ required = None
75
+ if hasattr(cls, "required_options"):
76
+ required = cls.required_options() # type: ignore[attr-defined]
77
+
78
+ resolved_product_name = getattr(cls, "PRODUCT_NAME", None)
79
+ product_name = (
80
+ resolved_product_name
81
+ if isinstance(resolved_product_name, str) and resolved_product_name
82
+ else None
83
+ )
84
+
85
+ return PluginDescriptor(
86
+ name=plugin,
87
+ module=cls.__module__,
88
+ description=description,
89
+ products=products,
90
+ defaults=defaults,
91
+ required_options=required,
92
+ product_name=product_name,
93
+ )
94
+
95
+
96
+ def list_plugin_descriptors() -> list[PluginDescriptor]:
97
+ return [get_plugin_descriptor(name) for name in list_plugins()]
@@ -0,0 +1,95 @@
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
+ """Plugin version compatibility check.
16
+
17
+ A plugin may declare ``firecube_core_min_version`` (string, PEP 440) on its
18
+ registered ingestor class. When ``discover_ingestors()`` discovers a plugin, the
19
+ declared minimum is compared against the running ``firecube.ingestor`` version.
20
+
21
+ The check is intentionally permissive: a missing declaration is fine, an
22
+ unparseable value is fine, and even an incompatible value only emits a warning.
23
+ The goal is to surface *informational* drift between the core plugin contract
24
+ and out-of-tree plugin builds, not to block ingestion.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Any
31
+
32
+ from firecube.ingestor import __version__ as CORE_VERSION
33
+
34
+ _LOG = logging.getLogger("firecube.registry.version_compat")
35
+
36
+ VERSION_ATTR = "firecube_core_min_version"
37
+
38
+
39
+ def _parse(value: str):
40
+ from packaging.version import InvalidVersion, Version
41
+
42
+ try:
43
+ return Version(value)
44
+ except InvalidVersion:
45
+ return None
46
+
47
+
48
+ def check_plugin_compatibility(
49
+ plugin_name: str,
50
+ plugin_cls: type[Any],
51
+ *,
52
+ core_version: str = CORE_VERSION,
53
+ ) -> str | None:
54
+ """Compare a plugin's declared minimum core version against ``core_version``.
55
+
56
+ Returns a human-readable warning message when the plugin requires a newer
57
+ core than the one currently running, otherwise ``None``. The message is
58
+ purely informational — callers decide whether to log/raise.
59
+ """
60
+ declared = getattr(plugin_cls, VERSION_ATTR, None)
61
+ if declared is None:
62
+ return None
63
+ if not isinstance(declared, str):
64
+ return None
65
+
66
+ plugin_min = _parse(declared)
67
+ core_now = _parse(core_version)
68
+ if plugin_min is None or core_now is None:
69
+ return None
70
+
71
+ if core_now >= plugin_min:
72
+ return None
73
+
74
+ return (
75
+ f"Plugin '{plugin_name}' requires firecube.ingestor>={declared}, "
76
+ f"but {core_version} is installed. The plugin may not work correctly."
77
+ )
78
+
79
+
80
+ def warn_if_incompatible(
81
+ plugin_name: str,
82
+ plugin_cls: type[Any],
83
+ *,
84
+ core_version: str = CORE_VERSION,
85
+ logger: logging.Logger | None = None,
86
+ ) -> bool:
87
+ """Emit a warning via ``logger`` when the plugin declares an incompatible minimum.
88
+
89
+ Returns True when a warning was emitted, False otherwise.
90
+ """
91
+ message = check_plugin_compatibility(plugin_name, plugin_cls, core_version=core_version)
92
+ if message is None:
93
+ return False
94
+ (logger or _LOG).warning(message)
95
+ return True
@@ -0,0 +1,14 @@
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
+
@@ -0,0 +1,112 @@
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
+ """Run-level metric aggregation utilities for plugin and engine integration."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ from firecube.ingestor.types.context import PipelineRunState, RuntimeIngestContext
24
+
25
+ _RESERVED_AGGREGATE_METRICS_KEYS = frozenset({"pipeline"})
26
+
27
+
28
+ def merge_batch_metrics(ctx: RuntimeIngestContext, state: PipelineRunState) -> dict[str, Any]:
29
+ """Merge successful batch metrics into one run-level mapping.
30
+
31
+ Default policy:
32
+ - Numeric values are summed.
33
+ - List values are concatenated.
34
+ - Other values keep the first successful value.
35
+ - Zarr coverage is always merged under ``metrics["zarr"]["coverage"]``.
36
+ """
37
+ merged_metrics: dict[str, Any] = {}
38
+ if ctx.output_format == "zarr":
39
+ merged_metrics["zarr"] = {"coverage": []}
40
+
41
+ for res in state.results:
42
+ if not res.success or not res.metrics:
43
+ continue
44
+
45
+ if "zarr" in res.metrics:
46
+ cov = res.metrics["zarr"].get("coverage")
47
+ if cov:
48
+ if "zarr" not in merged_metrics:
49
+ merged_metrics["zarr"] = {"coverage": []}
50
+ merged_metrics["zarr"]["coverage"].extend(cov)
51
+
52
+ for key, value in res.metrics.items():
53
+ if key == "zarr":
54
+ continue
55
+ if isinstance(value, (int, float)):
56
+ current = merged_metrics.get(key, 0)
57
+ if isinstance(current, (int, float)):
58
+ merged_metrics[key] = current + value
59
+ else:
60
+ merged_metrics[key] = value
61
+ elif isinstance(value, list):
62
+ if key not in merged_metrics:
63
+ merged_metrics[key] = []
64
+ if isinstance(merged_metrics[key], list):
65
+ merged_metrics[key].extend(value)
66
+ else:
67
+ merged_metrics.setdefault(key, value)
68
+
69
+ return merged_metrics
70
+
71
+
72
+ def normalize_plugin_aggregate_metrics(
73
+ raw_metrics: Any,
74
+ *,
75
+ logger: logging.Logger,
76
+ plugin_name: str,
77
+ ) -> dict[str, Any]:
78
+ """Normalize plugin aggregate metrics and enforce engine-owned key boundaries."""
79
+ if raw_metrics is None:
80
+ return {}
81
+ if not isinstance(raw_metrics, Mapping):
82
+ logger.warning(
83
+ "Plugin '%s' returned non-mapping aggregate metrics (%s); using empty metrics.",
84
+ plugin_name,
85
+ type(raw_metrics).__name__,
86
+ )
87
+ return {}
88
+
89
+ normalized: dict[str, Any] = {}
90
+ dropped_keys: list[str] = []
91
+ for key, value in raw_metrics.items():
92
+ if not isinstance(key, str):
93
+ dropped_keys.append(repr(key))
94
+ continue
95
+ normalized[key] = value
96
+
97
+ if dropped_keys:
98
+ logger.warning(
99
+ "Plugin '%s' returned non-string aggregate metric keys; ignoring: %s",
100
+ plugin_name,
101
+ ", ".join(sorted(dropped_keys)),
102
+ )
103
+
104
+ for reserved_key in _RESERVED_AGGREGATE_METRICS_KEYS:
105
+ if reserved_key in normalized:
106
+ logger.warning(
107
+ "Plugin '%s' returned reserved aggregate metrics key '%s'; ignoring plugin value.",
108
+ plugin_name,
109
+ reserved_key,
110
+ )
111
+ normalized.pop(reserved_key, None)
112
+ return normalized