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
firecube/__init__.py ADDED
@@ -0,0 +1,23 @@
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
+ """Firecube package."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from firecube._version import VERSION
20
+
21
+ __version__ = VERSION
22
+
23
+ __all__ = ["__version__"]
firecube/__main__.py ADDED
@@ -0,0 +1,20 @@
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
+ """Firecube CLI entrypoint."""
16
+
17
+ from firecube.cli.main import cli
18
+
19
+ if __name__ == "__main__":
20
+ cli()
firecube/_version.py ADDED
@@ -0,0 +1,46 @@
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
+ """Resolve Firecube's package version from installed metadata or pyproject.toml."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.metadata
20
+ import tomllib
21
+ from pathlib import Path
22
+
23
+ _DISTRIBUTION_NAME = "firecube"
24
+ _UNKNOWN_VERSION = "0.0.0+unknown"
25
+
26
+
27
+ def _pyproject_version() -> str | None:
28
+ current = Path(__file__).resolve()
29
+ for parent in current.parents[:4]:
30
+ pyproject = parent / "pyproject.toml"
31
+ if not pyproject.exists():
32
+ continue
33
+ data = tomllib.loads(pyproject.read_text())
34
+ version = data.get("project", {}).get("version")
35
+ return version if isinstance(version, str) and version else None
36
+ return None
37
+
38
+
39
+ def get_version() -> str:
40
+ try:
41
+ return importlib.metadata.version(_DISTRIBUTION_NAME)
42
+ except importlib.metadata.PackageNotFoundError:
43
+ return _pyproject_version() or _UNKNOWN_VERSION
44
+
45
+
46
+ VERSION = get_version()
@@ -0,0 +1,135 @@
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 Literal
18
+
19
+ import click
20
+
21
+ USER_FACING_PATHS: frozenset[tuple[str, ...]] = frozenset(
22
+ {
23
+ ("ingest",),
24
+ ("zarr", "validate"),
25
+ ("zarr", "multires"),
26
+ ("zarr", "preallocate"),
27
+ ("zarr", "slots"),
28
+ ("parquet", "validate"),
29
+ ("parquet", "consolidate"),
30
+ ("plugins", "list"),
31
+ ("plugins", "describe"),
32
+ ("plugins", "explain"),
33
+ ("plugins", "create"),
34
+ ("plugins", "install"),
35
+ ("plugins", "uninstall"),
36
+ ("advise", "batch-size"),
37
+ ("advise", "compliance"),
38
+ ("completion",),
39
+ }
40
+ )
41
+
42
+ OPERATOR_FACING_PATHS: frozenset[tuple[str, ...]] = frozenset(
43
+ {
44
+ ("chunks", "list"),
45
+ ("chunks", "delete"),
46
+ ("chunks", "delete-span"),
47
+ ("chunks", "claims", "list"),
48
+ ("chunks", "claims", "clear"),
49
+ ("chunks", "runs", "list"),
50
+ ("chunks", "runs", "abandon"),
51
+ ("chunks", "snapshots", "rebuild"),
52
+ ("chunks", "snapshots", "status"),
53
+ ("archive", "create"),
54
+ ("archive", "restore"),
55
+ ("archive", "info"),
56
+ ("archive", "validate"),
57
+ ("archive", "list"),
58
+ ("catalog", "intake"),
59
+ }
60
+ )
61
+
62
+ TIER_1_PATHS: frozenset[tuple[str, ...]] = frozenset(
63
+ {
64
+ ("chunks", "delete"),
65
+ ("chunks", "delete-span"),
66
+ ("archive", "create"),
67
+ ("archive", "restore"),
68
+ }
69
+ )
70
+
71
+ TIER_2_PATHS: frozenset[tuple[str, ...]] = frozenset(
72
+ {
73
+ ("chunks", "runs", "abandon"),
74
+ ("chunks", "claims", "clear"),
75
+ }
76
+ )
77
+
78
+ TIER_3_PATHS: frozenset[tuple[str, ...]] = frozenset(
79
+ {
80
+ ("chunks", "snapshots", "rebuild"),
81
+ ("zarr", "preallocate"),
82
+ }
83
+ )
84
+
85
+ INTERNAL_TOKENS_USER_FORBIDDEN = frozenset(
86
+ {
87
+ ".firecube",
88
+ "WAL",
89
+ "control-plane",
90
+ "DirectZarrIngestor",
91
+ "JOB_COMPLETION_INDEX",
92
+ "SUPPORTS_SLOT_RANGE_PARALLELISM",
93
+ "SchemaSizeMismatchError",
94
+ }
95
+ )
96
+
97
+ GROUP_AUDIENCE: dict[str, Literal["user", "operator"]] = {
98
+ "zarr": "user",
99
+ "archive": "operator",
100
+ "parquet": "user",
101
+ "chunks": "operator",
102
+ "advise": "user",
103
+ "plugins": "user",
104
+ "catalog": "operator",
105
+ }
106
+
107
+ _AUDIENCE_PATHS: dict[tuple[str, ...], Literal["user", "operator"]] = dict.fromkeys(
108
+ USER_FACING_PATHS, "user"
109
+ )
110
+ _AUDIENCE_PATHS.update(dict.fromkeys(OPERATOR_FACING_PATHS, "operator"))
111
+
112
+
113
+ def classify(command_path: tuple[str, ...]) -> Literal["user", "operator"]:
114
+ """Return audience class for a command path.
115
+
116
+ Raises ``click.ClickException`` for unknown paths so the CLI boundary
117
+ renders a clean error instead of a Python traceback.
118
+ """
119
+
120
+ try:
121
+ return _AUDIENCE_PATHS[command_path]
122
+ except KeyError as exc:
123
+ raise click.ClickException(f"Unknown command path: {command_path!r}") from exc
124
+
125
+
126
+ __all__ = [
127
+ "GROUP_AUDIENCE",
128
+ "INTERNAL_TOKENS_USER_FORBIDDEN",
129
+ "OPERATOR_FACING_PATHS",
130
+ "TIER_1_PATHS",
131
+ "TIER_2_PATHS",
132
+ "TIER_3_PATHS",
133
+ "USER_FACING_PATHS",
134
+ "classify",
135
+ ]
@@ -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
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import click
22
+
23
+ from firecube.cli._uri_policy import SCHEME_TO_STORAGE_TYPE
24
+ from firecube.core.uris import infer_target_protocol
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class IngestCommandConfig:
29
+ """Validated ingest command configuration.
30
+
31
+ Uses aggregate validation to report all missing/invalid command-level values at once.
32
+ """
33
+
34
+ plugin: str
35
+ input_data: str | Path | None
36
+ target: str
37
+ write_mode: str | None
38
+ storage_type: str | None
39
+ storage_driver: str | None
40
+ product_name: str | None = None
41
+ output_format: str = "zarr"
42
+ in_memory: bool = False
43
+ options: dict[str, Any] = field(default_factory=dict)
44
+
45
+ _VALID_WRITE_MODES = frozenset({"staged", "direct"})
46
+ _VALID_STORAGE_TYPES = frozenset({"local", "s3"})
47
+ _VALID_STORAGE_DRIVERS = frozenset({"fsspec", "obstore"})
48
+
49
+ def __post_init__(self) -> None:
50
+ """Aggregate all validation errors into a single Click usage error."""
51
+ errors: list[str] = []
52
+
53
+ if self.product_name is not None and self.product_name == "":
54
+ errors.append(
55
+ "--product-name was provided but is empty. Either omit it (use plugin "
56
+ "PRODUCT_NAME or config default_product_name), or provide a non-empty value."
57
+ )
58
+
59
+ if not self.target:
60
+ errors.append("--target is required.")
61
+
62
+ if self.write_mode is None:
63
+ errors.append(
64
+ "--write-mode is required. No inference from target locality. "
65
+ "Choose: staged (workspace-first then upload) or direct (stream to target)."
66
+ )
67
+ elif self.write_mode not in self._VALID_WRITE_MODES:
68
+ errors.append(
69
+ f"--write-mode must be one of: {', '.join(sorted(self._VALID_WRITE_MODES))} "
70
+ f"(got '{self.write_mode}')"
71
+ )
72
+
73
+ if self.storage_type is not None and self.storage_type not in self._VALID_STORAGE_TYPES:
74
+ errors.append(
75
+ f"--storage-type must be one of: {', '.join(sorted(self._VALID_STORAGE_TYPES))} "
76
+ f"(got '{self.storage_type}')"
77
+ )
78
+ if self.storage_type is not None and self.target:
79
+ try:
80
+ scheme = infer_target_protocol(self.target)
81
+ except ValueError as exc:
82
+ errors.append(str(exc))
83
+ else:
84
+ expected_storage_type = SCHEME_TO_STORAGE_TYPE.get(scheme)
85
+ if expected_storage_type is None:
86
+ errors.append(
87
+ f"Target URI scheme '{scheme}' is not supported. Use a file:// URI for "
88
+ "--storage-type local, or an s3:// URI for --storage-type s3."
89
+ )
90
+ elif expected_storage_type != self.storage_type:
91
+ alternate_target = (
92
+ "an s3://-compatible URI" if self.storage_type == "s3" else "a file:// URI"
93
+ )
94
+ errors.append(
95
+ f"Target URI scheme '{scheme}' is incompatible with --storage-type "
96
+ f"'{self.storage_type}'. Use --storage-type {expected_storage_type} "
97
+ f"for {scheme}:// targets, or change --target to {alternate_target}."
98
+ )
99
+
100
+ if (
101
+ self.storage_driver is not None
102
+ and self.storage_driver not in self._VALID_STORAGE_DRIVERS
103
+ ):
104
+ errors.append(
105
+ f"--storage-driver must be one of: {', '.join(sorted(self._VALID_STORAGE_DRIVERS))} "
106
+ f"(got '{self.storage_driver}')"
107
+ )
108
+
109
+ if errors:
110
+ raise click.UsageError(
111
+ "Invalid ingest configuration:\n" + "\n".join(f" - {error}" for error in errors)
112
+ )
firecube/cli/_ctx.py ADDED
@@ -0,0 +1,85 @@
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
+ import logging
18
+ from collections.abc import Mapping
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import click
23
+
24
+ from firecube.core.config import StorageConfig, load_config_file
25
+ from firecube.core.runtime import identity_from_storage_config, resolve_storage_config
26
+
27
+ log = logging.getLogger("firecube.cli")
28
+
29
+
30
+ def get_config_file(ctx: click.Context) -> Path | None:
31
+ ctx.ensure_object(dict)
32
+ value = ctx.obj.get("config_file")
33
+ return value if isinstance(value, Path) else None
34
+
35
+
36
+ def get_config(ctx: click.Context) -> dict[str, Any]:
37
+ """Load config TOML as a plain dictionary."""
38
+ return load_config_file(get_config_file(ctx))
39
+
40
+
41
+ def get_storage_config(
42
+ ctx: click.Context,
43
+ *,
44
+ overrides: Mapping[str, str | None] | None = None,
45
+ set_global: bool = False,
46
+ cache: bool = True,
47
+ ) -> StorageConfig:
48
+ """Resolve StorageConfig from config + env + optional overrides.
49
+
50
+ This centralizes common CLI behavior:
51
+ - reads `--config-file` stored in `ctx.obj["config_file"]`
52
+ - caches the resolved StorageConfig in `ctx.obj["storage_config"]`
53
+ - wraps errors as Click-friendly exceptions
54
+ """
55
+ ctx.ensure_object(dict)
56
+ if cache and overrides is None:
57
+ cached = ctx.obj.get("storage_config")
58
+ if isinstance(cached, StorageConfig):
59
+ return cached
60
+
61
+ try:
62
+ storage_config = resolve_storage_config(
63
+ config_file=get_config_file(ctx),
64
+ overrides=dict(overrides) if overrides is not None else None,
65
+ set_global=set_global,
66
+ )
67
+ except Exception as exc:
68
+ raise click.ClickException(f"Failed to resolve storage configuration: {exc}") from exc
69
+
70
+ if cache and overrides is None:
71
+ ctx.obj["storage_config"] = storage_config
72
+ identity = identity_from_storage_config(storage_config)
73
+ product_uri = identity.product_uri if identity is not None else None
74
+ bucket = product_uri.authority if product_uri is not None else None
75
+ target_path = (
76
+ product_uri.path if product_uri is not None and product_uri.protocol == "file" else None
77
+ )
78
+ log.debug(
79
+ "Resolved StorageConfig (type=%s bucket=%s endpoint=%s target=%s)",
80
+ storage_config.storage_type,
81
+ bucket,
82
+ storage_config.endpoint_url,
83
+ target_path,
84
+ )
85
+ return storage_config
@@ -0,0 +1,115 @@
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
+ import errno
18
+ import functools
19
+ from collections.abc import Callable
20
+
21
+ import click
22
+
23
+ _KNOWN_USER_ERROR_TYPE_NAMES: frozenset[str] = frozenset(
24
+ {
25
+ "ConfigurationError",
26
+ "GroupNotFoundError",
27
+ "FileNotFoundError",
28
+ "NodeNotFoundError",
29
+ "NotADirectoryError",
30
+ "PathNotFoundError",
31
+ "PermissionError",
32
+ }
33
+ )
34
+
35
+
36
+ def _is_known_user_error(exc: BaseException) -> bool:
37
+ return type(exc).__name__ in _KNOWN_USER_ERROR_TYPE_NAMES
38
+
39
+
40
+ def _is_known_user_oserror(exc: BaseException) -> bool:
41
+ """True when exc is an OS-level error that is the user's fault (not infra)."""
42
+ return isinstance(exc, OSError) and exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.EACCES}
43
+
44
+
45
+ def wrap_user_facing_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
46
+ """Convert known downstream errors to ``click.ClickException`` at the CLI boundary.
47
+
48
+ Only exceptions whose class name appears in ``_KNOWN_USER_ERROR_TYPE_NAMES``
49
+ are wrapped; ``click.ClickException`` and ``click.exceptions.Exit`` pass
50
+ through unchanged, and any other exception propagates so operators see the
51
+ real traceback.
52
+ """
53
+
54
+ @functools.wraps(func)
55
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
56
+ try:
57
+ return func(*args, **kwargs)
58
+ except click.exceptions.Exit:
59
+ raise
60
+ except click.ClickException:
61
+ raise
62
+ except Exception as exc:
63
+ if _is_known_user_error(exc) or _is_known_user_oserror(exc):
64
+ raise click.ClickException(str(exc)) from exc
65
+ raise
66
+
67
+ return wrapper
68
+
69
+
70
+ class UnknownOptionError(click.BadParameter):
71
+ def __init__(self, key: str, plugin: str, valid_keys: list[str]) -> None:
72
+ msg = (
73
+ f"Unknown option '{key}' for plugin '{plugin}'.\n"
74
+ f"Valid keys ({len(valid_keys)}): {', '.join(sorted(valid_keys))}\n"
75
+ f"Hint: run `firecube ingest {plugin} --show-options` to inspect. "
76
+ f"Experimental options must use the x_ prefix (e.g. --option x_foo=...)."
77
+ )
78
+ super().__init__(msg)
79
+
80
+
81
+ class MissingProductNameError(click.UsageError):
82
+ def __init__(self, plugin: str) -> None:
83
+ super().__init__(
84
+ f"Missing product name for plugin '{plugin}'. Provide one of:\n"
85
+ f" 1. CLI flag: --product-name <name>\n"
86
+ f" 2. Config file: [plugins.{plugin}]\n default_product_name = '<name>'\n"
87
+ f" 3. Plugin class attr: {plugin}.PRODUCT_NAME = '<name>' (preferred; declared at plugin source)"
88
+ )
89
+
90
+
91
+ class MissingStorageTypeError(click.UsageError):
92
+ def __init__(self, target_uri: str) -> None:
93
+ super().__init__(
94
+ f"Missing --storage-type for target '{target_uri}'.\n"
95
+ f"Storage type is no longer inferred from URI scheme. Provide explicitly:\n"
96
+ f" --storage-type [local|s3]"
97
+ )
98
+
99
+
100
+ class MissingStorageDriverError(click.UsageError):
101
+ def __init__(self, target_uri: str) -> None:
102
+ super().__init__(
103
+ f"Missing --storage-driver for target '{target_uri}'. Provide explicitly:\n"
104
+ f" --storage-driver [fsspec|obstore]"
105
+ )
106
+
107
+
108
+ class MissingWriteModeError(click.UsageError):
109
+ def __init__(self) -> None:
110
+ super().__init__(
111
+ "Missing --write-mode. Required for all targets (no local-default inference). "
112
+ "Choose:\n"
113
+ " --write-mode staged : workspace-first, then upload\n"
114
+ " --write-mode direct : stream directly to target\n"
115
+ )