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,102 @@
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
+ """K8s/env slot-range discovery with explicit precedence rules.
16
+
17
+ Precedence (highest to lowest):
18
+ 1. CLI --slot-start/--slot-end (already parsed; passed as cli_slot_start/cli_slot_end)
19
+ 2. FIRECUBE_SLOT_START + FIRECUBE_SLOT_END env vars (both required)
20
+ 3. JOB_COMPLETION_INDEX + (--slot-size OR FIRECUBE_SLOT_SIZE) -> slot_start = index * size
21
+ 4. None (single-pod mode)
22
+
23
+ Slot group precedence is independent:
24
+ 1. CLI --slot-group
25
+ 2. FIRECUBE_SLOT_GROUP
26
+ 3. None
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+
34
+ import click
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ def _parse_int(value: str, *, name: str) -> int:
40
+ try:
41
+ return int(value)
42
+ except ValueError as exc:
43
+ raise click.UsageError(f"{name} must be an integer, got {value!r}") from exc
44
+
45
+
46
+ def resolve_slot_range_from_env(
47
+ cli_slot_start: int | None,
48
+ cli_slot_end: int | None,
49
+ cli_slot_size: int | None,
50
+ cli_slot_group: str | None = None,
51
+ ) -> tuple[int | None, int | None, str | None]:
52
+ """Resolve final (slot_start, slot_end, slot_group) from CLI args + environment."""
53
+ slot_group = cli_slot_group
54
+ env_slot_group = os.getenv("FIRECUBE_SLOT_GROUP")
55
+ if slot_group is None and env_slot_group is not None:
56
+ slot_group = env_slot_group
57
+
58
+ if cli_slot_start is not None and cli_slot_end is not None:
59
+ return cli_slot_start, cli_slot_end, slot_group
60
+
61
+ env_slot_start = os.getenv("FIRECUBE_SLOT_START")
62
+ env_slot_end = os.getenv("FIRECUBE_SLOT_END")
63
+ if env_slot_start is not None and env_slot_end is not None:
64
+ start = _parse_int(env_slot_start, name="FIRECUBE_SLOT_START")
65
+ end = _parse_int(env_slot_end, name="FIRECUBE_SLOT_END")
66
+ log.info(
67
+ "slot_range resolved from env vars FIRECUBE_SLOT_START/END: [%s, %s)",
68
+ start,
69
+ end,
70
+ )
71
+ return start, end, slot_group
72
+
73
+ job_completion_index = os.getenv("JOB_COMPLETION_INDEX")
74
+ if job_completion_index is not None:
75
+ slot_size = cli_slot_size
76
+ if slot_size is None:
77
+ env_slot_size = os.getenv("FIRECUBE_SLOT_SIZE")
78
+ slot_size = (
79
+ _parse_int(env_slot_size, name="FIRECUBE_SLOT_SIZE")
80
+ if env_slot_size is not None
81
+ else None
82
+ )
83
+
84
+ if slot_size is None:
85
+ log.warning(
86
+ "JOB_COMPLETION_INDEX detected but no slot size; parallel mode NOT activated"
87
+ )
88
+ return None, None, slot_group
89
+
90
+ index = _parse_int(job_completion_index, name="JOB_COMPLETION_INDEX")
91
+ start = index * slot_size
92
+ end = (index + 1) * slot_size
93
+ log.info(
94
+ "slot_range resolved from JOB_COMPLETION_INDEX=%s * slot_size=%s: [%s, %s)",
95
+ index,
96
+ slot_size,
97
+ start,
98
+ end,
99
+ )
100
+ return start, end, slot_group
101
+
102
+ return None, None, slot_group
@@ -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
+ """Reusable slot-planning helpers for firecube zarr slots and parallel ingestion.
16
+
17
+ Extracted from plan.py to be importable by firecube zarr slots without
18
+ coupling to the legacy plan command.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from collections.abc import Sequence
25
+
26
+ import click
27
+
28
+ from firecube.ingestor.api import ZarrGroupSpec
29
+
30
+ #: Plan JSON schema version. Independent of control-plane SCHEMA_VERSION.
31
+ PLAN_SCHEMA_VERSION = "v1"
32
+
33
+
34
+ def _resolve_per_group_slot_sizes(
35
+ schema: Sequence[ZarrGroupSpec],
36
+ explicit: int | None,
37
+ ) -> dict[str, int]:
38
+ if explicit is not None:
39
+ if explicit <= 0:
40
+ raise click.ClickException(f"--slot-size must be > 0, got {explicit}")
41
+ explicit = int(explicit)
42
+
43
+ per_group: dict[str, int] = {}
44
+ for group_spec in schema:
45
+ time_chunks = [
46
+ int(arr_spec.chunks[0])
47
+ for arr_spec in group_spec.arrays
48
+ if arr_spec.chunks is not None and arr_spec.chunks
49
+ ]
50
+ if not time_chunks:
51
+ continue
52
+ per_group[group_spec.group] = math.lcm(*time_chunks)
53
+
54
+ if explicit is not None:
55
+ invalid = {g: req for g, req in per_group.items() if explicit % req != 0}
56
+ if invalid:
57
+ details = "; ".join(
58
+ f"group '{g}' needs multiple of {req}" for g, req in sorted(invalid.items())
59
+ )
60
+ raise click.ClickException(f"--slot-size={explicit} not valid: {details}")
61
+ return dict.fromkeys(per_group, explicit)
62
+ return per_group
63
+
64
+
65
+ def _chunk_aligned_remaining(
66
+ remaining: list[tuple[int, int]], slot_size: int, total: int
67
+ ) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
68
+ """Split each ``remaining`` interval into chunk-aligned + blocked sub-intervals.
69
+
70
+ Returns ``(aligned, blocked)`` where:
71
+ - ``aligned`` is the executable list of chunk-aligned intervals (Phase 3.4 behavior preserved)
72
+ - ``blocked`` is the list of sub-intervals that cannot produce any aligned executable range:
73
+ * pre-alignment prefix when ``start`` is misaligned (e.g. ``[(73, 1000)]`` slot=100 →
74
+ aligned=[(100, 1000)], blocked=[(73, 100)])
75
+ * fully-dropped interval when ``aligned_start >= clamped_end`` (e.g. ``[(950, 1000)]``
76
+ slot=100 total=1000 → aligned=[], blocked=[(950, 1000)])
77
+
78
+ Callers MUST treat non-empty ``blocked`` as a fail-closed condition. Phase 3.5 introduced
79
+ this signature change to close the silent-drop bug surfaced by external review.
80
+ """
81
+ aligned: list[tuple[int, int]] = []
82
+ blocked: list[tuple[int, int]] = []
83
+ for start, end in remaining:
84
+ if start % slot_size != 0:
85
+ aligned_start = ((start // slot_size) + 1) * slot_size
86
+ else:
87
+ aligned_start = start
88
+ clamped_end = min(end, total)
89
+ if aligned_start < clamped_end:
90
+ aligned.append((aligned_start, clamped_end))
91
+ if aligned_start > start:
92
+ blocked.append((start, aligned_start))
93
+ else:
94
+ blocked.append((start, clamped_end))
95
+ return aligned, blocked
@@ -0,0 +1,181 @@
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
+ from __future__ import annotations
16
+
17
+ from typing import Any, get_type_hints
18
+
19
+ import click
20
+
21
+ from firecube.cli._errors import UnknownOptionError
22
+ from firecube.cli._shared_options import format_option as display_format_option
23
+ from firecube.cli.plugins.registry import PluginConfigSchemas, resolve_plugin_configs
24
+ from firecube.ingestor.config.coercion import coerce_cli_value
25
+ from firecube.ingestor.config.engine import is_experimental_option_key
26
+
27
+ __all__ = [
28
+ "TypedOptionsParam",
29
+ "coerce_options_for_plugin",
30
+ "display_format_option",
31
+ "output_path_option",
32
+ ]
33
+
34
+
35
+ def output_path_option(
36
+ required: bool = True,
37
+ help_text: str = "destination file path",
38
+ ) -> Any:
39
+ """Shared ``-o/--output`` decorator for file destination paths.
40
+
41
+ The decorated callback receives the value as ``output_path``. The type is
42
+ ``click.Path()`` (string path), distinct from ``workspace_option`` which uses
43
+ ``click.Path(path_type=Path)``.
44
+
45
+ Args:
46
+ required: Whether ``--output`` must be provided.
47
+ help_text: Help text displayed in ``--help`` output.
48
+ """
49
+
50
+ def decorator(f: Any) -> Any:
51
+ return click.option(
52
+ "-o",
53
+ "--output",
54
+ "output_path",
55
+ required=required,
56
+ type=click.Path(),
57
+ help=help_text,
58
+ )(f)
59
+
60
+ return decorator
61
+
62
+
63
+ # Keys owned by dedicated `firecube ingest` flags. They are valid EngineConfig
64
+ # fields, so they would pass unknown-key validation — but the free-form merge
65
+ # happens after typed-flag resolution and would silently override the explicit
66
+ # flag. Hard-reject at parse time.
67
+ _TYPED_FLAG_OWNED_KEYS: dict[str, str] = {
68
+ "write_mode": "--write-mode",
69
+ "slot_start": "--slot-start",
70
+ "slot_end": "--slot-end",
71
+ "slot_size": "--slot-size",
72
+ "slot_group": "--slot-group",
73
+ }
74
+
75
+
76
+ class TypedOptionsParam(click.ParamType):
77
+ """Parse-time validation of ``--option key=value`` pairs against plugin schema."""
78
+
79
+ name = "TYPED_OPTION"
80
+
81
+ def __init__(self, plugin_name: str | None = None) -> None:
82
+ """Create a typed option parser.
83
+
84
+ Args:
85
+ plugin_name: Optional plugin name for parse-time schema validation. If omitted,
86
+ the parser attempts to read ``plugin`` from the Click context.
87
+ """
88
+ self._plugin_name = plugin_name
89
+
90
+ def convert(
91
+ self,
92
+ value: str,
93
+ param: click.Parameter | None,
94
+ ctx: click.Context | None,
95
+ ) -> tuple[str, Any]:
96
+ if not isinstance(value, str):
97
+ self.fail("Option must be a key=value string.", param, ctx)
98
+
99
+ if "=" not in value:
100
+ self.fail("Use key=value syntax (e.g., --option pipeline_workers=4).", param, ctx)
101
+
102
+ key, raw_value = value.split("=", 1)
103
+ key = key.strip()
104
+
105
+ if not key:
106
+ self.fail("Option key cannot be empty.", param, ctx)
107
+ if raw_value == "":
108
+ self.fail(f"Option value for '{key}' cannot be empty.", param, ctx)
109
+
110
+ owning_flag = _TYPED_FLAG_OWNED_KEYS.get(key)
111
+ if owning_flag is not None:
112
+ self.fail(
113
+ f"'{key}' is owned by the typed flag {owning_flag}; "
114
+ f"pass {owning_flag} instead of --option {key}=...",
115
+ param,
116
+ ctx,
117
+ )
118
+
119
+ if is_experimental_option_key(key):
120
+ return key, raw_value
121
+
122
+ plugin_name = self._plugin_name
123
+ if plugin_name is None and ctx is not None:
124
+ plugin_name = ctx.params.get("plugin")
125
+
126
+ if plugin_name:
127
+ try:
128
+ schemas = resolve_plugin_configs(str(plugin_name))
129
+ valid_keys = _enumerate_all_valid_keys(schemas)
130
+ if valid_keys and key not in valid_keys:
131
+ raise UnknownOptionError(key, str(plugin_name), sorted(valid_keys))
132
+
133
+ target_type = _get_field_type(schemas, key)
134
+ if target_type is not None:
135
+ try:
136
+ return key, coerce_cli_value(raw_value, target_type, key)
137
+ except (TypeError, ValueError) as exc:
138
+ self.fail(str(exc), param, ctx)
139
+ except (ImportError, AttributeError, KeyError):
140
+ pass
141
+
142
+ return key, raw_value
143
+
144
+
145
+ def coerce_options_for_plugin(
146
+ plugin: str,
147
+ extra_options: tuple[tuple[str, object], ...],
148
+ ) -> tuple[tuple[str, object], ...]:
149
+ """Validate/coerce parsed ``--option`` pairs against a resolved plugin name."""
150
+ typed_param = TypedOptionsParam(plugin)
151
+ coerced: list[tuple[str, object]] = []
152
+ for key, value in extra_options:
153
+ if isinstance(value, str):
154
+ coerced.append(typed_param.convert(f"{key}={value}", None, None))
155
+ else:
156
+ coerced.append((key, value))
157
+ return tuple(coerced)
158
+
159
+
160
+ def _enumerate_all_valid_keys(schemas: PluginConfigSchemas) -> set[str]:
161
+ """Gather all valid ``--option`` keys across engine, template, and plugin tiers."""
162
+ from firecube.ingestor.config.engine import config_keys
163
+
164
+ keys: set[str] = set()
165
+ keys.update(config_keys(schemas.engine))
166
+ if schemas.template is not None:
167
+ keys.update(config_keys(schemas.template))
168
+ if schemas.plugin is not None:
169
+ keys.update(config_keys(schemas.plugin))
170
+ return keys
171
+
172
+
173
+ def _get_field_type(schemas: PluginConfigSchemas, key: str) -> Any | None:
174
+ """Return the expected Python annotation for an option key from any tier."""
175
+ for cls in (schemas.engine, schemas.template, schemas.plugin):
176
+ if cls is None:
177
+ continue
178
+ hints = get_type_hints(cls)
179
+ if key in hints:
180
+ return hints[key]
181
+ return None
@@ -0,0 +1,110 @@
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
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Final
21
+ from urllib.parse import urlparse
22
+
23
+ import click
24
+
25
+ from firecube.core.storage.uri import StorageUri
26
+
27
+ SCHEME_TO_STORAGE_TYPE: Final[Mapping[str, str]] = {"file": "local", "s3": "s3"}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ParsedUri:
32
+ raw: str
33
+ scheme: str
34
+ normalized: str
35
+ local: bool
36
+
37
+
38
+ def parse_product_uri(raw: str) -> ParsedUri:
39
+ """Parse product URI strings accepted at the CLI boundary."""
40
+ candidate = str(raw or "")
41
+ if not candidate:
42
+ raise click.UsageError("Product URI must not be empty.")
43
+
44
+ if "://" not in candidate:
45
+ suggestion = Path(candidate).expanduser().resolve().as_uri()
46
+ raise click.UsageError(
47
+ f"URI scheme required (file:// or s3://). Did you mean {suggestion}?"
48
+ )
49
+
50
+ parsed = urlparse(candidate)
51
+ scheme = parsed.scheme.lower()
52
+ if scheme == "file" and parsed.path in ("", "/"):
53
+ raise click.UsageError("file:// URI requires a non-empty path")
54
+ if scheme == "file":
55
+ return _parse_file_uri(candidate, parsed.netloc)
56
+ if scheme == "s3":
57
+ return _parse_s3_uri(candidate)
58
+ raise click.UsageError(f"URI scheme '{scheme}' not supported.")
59
+
60
+
61
+ def resolve_storage_type(uri: ParsedUri) -> str | None:
62
+ """Return the storage type matching a parsed product URI, if known."""
63
+ return SCHEME_TO_STORAGE_TYPE.get(uri.scheme)
64
+
65
+
66
+ def validate_uri_storage_coherence(uri: ParsedUri, storage_type: str) -> None:
67
+ """Raise UsageError when an explicit storage type contradicts the URI scheme."""
68
+ expected = resolve_storage_type(uri)
69
+ if expected is None:
70
+ raise click.UsageError(f"URI scheme '{uri.scheme}' not supported.")
71
+ if expected == storage_type:
72
+ return
73
+
74
+ alternate = "an s3:// URI" if storage_type == "s3" else "a file:// URI"
75
+ raise click.UsageError(
76
+ f"--storage-type '{storage_type}' is incompatible with URI scheme '{uri.scheme}'. "
77
+ f"Use --storage-type {expected} for {uri.scheme}:// targets, "
78
+ f"or change --target to {alternate}."
79
+ )
80
+
81
+
82
+ def apply_smart_default(uri: ParsedUri, storage_type: str | None) -> str:
83
+ """Resolve storage_type: explicit CLI value wins, else inferred from scheme."""
84
+ if storage_type is not None:
85
+ validate_uri_storage_coherence(uri, storage_type)
86
+ return storage_type
87
+ return SCHEME_TO_STORAGE_TYPE[uri.scheme]
88
+
89
+
90
+ def _parse_file_uri(raw: str, host: str) -> ParsedUri:
91
+ if host:
92
+ raise click.UsageError(
93
+ f"URI scheme 'file' with non-local host '{host}' not supported. "
94
+ "Use file:///path (three slashes, no host)."
95
+ )
96
+ try:
97
+ uri = StorageUri.parse(raw)
98
+ except ValueError as exc:
99
+ raise click.UsageError(str(exc)) from exc
100
+ return ParsedUri(raw=raw, scheme="file", normalized=Path(uri.path).as_uri(), local=True)
101
+
102
+
103
+ def _parse_s3_uri(raw: str) -> ParsedUri:
104
+ try:
105
+ uri = StorageUri.parse(raw)
106
+ except ValueError as exc:
107
+ if "authority (bucket) is required" in str(exc):
108
+ raise click.UsageError("authority bucket is required for s3 URIs") from exc
109
+ raise click.UsageError(str(exc)) from exc
110
+ return ParsedUri(raw=raw, scheme="s3", normalized=uri.to_str(), local=False)