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,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
+ """Custom Click formatter for clean, grouped help output."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ from collections.abc import Iterable
21
+ from inspect import cleandoc
22
+ from typing import Any
23
+
24
+ import click
25
+
26
+ MAX_WIDTH = 100
27
+
28
+ # Populated in main.py
29
+ COMMAND_GROUPS: dict[str, list[dict[str, Any]]] = {}
30
+ OPTION_GROUPS: dict[str, list[dict[str, Any]]] = {}
31
+
32
+
33
+ class FirecubeFormatter(click.HelpFormatter):
34
+ """Help formatter: section headers at col 0, tight alignment."""
35
+
36
+ def __init__(
37
+ self,
38
+ width: int | None = None,
39
+ max_width: int | None = None,
40
+ **kwargs: Any,
41
+ ) -> None:
42
+ terminal_width = min(shutil.get_terminal_size((80, 24)).columns, MAX_WIDTH)
43
+ resolved_width = width or terminal_width
44
+ resolved_max_width = max_width or MAX_WIDTH
45
+ super().__init__(width=resolved_width, max_width=resolved_max_width, **kwargs)
46
+
47
+ def write_heading(self, heading: str) -> None:
48
+ """Write 'Heading:' at column 0."""
49
+ self.write(f"\n{heading}:\n")
50
+
51
+ def write_dl(
52
+ self,
53
+ rows: Iterable[tuple[str, str]],
54
+ col_max: int = 80,
55
+ col_spacing: int = 2,
56
+ ) -> None:
57
+ """Write definition list with tight alignment."""
58
+ rows_list = list(rows)
59
+ if not rows_list:
60
+ return
61
+
62
+ max_term = max((len(term) for term, _ in rows_list if term), default=0)
63
+ col_width = min(max_term + col_spacing, col_max)
64
+
65
+ for term, definition in rows_list:
66
+ if not term:
67
+ if definition:
68
+ self.write(f"\n{definition}:\n")
69
+ continue
70
+
71
+ if definition:
72
+ self.write(f" {term:<{col_width}}{definition}\n")
73
+ else:
74
+ self.write(f" {term}\n")
75
+
76
+
77
+ class FirecubeGroup(click.Group):
78
+ """Click Group with COMMAND_GROUPS section rendering."""
79
+
80
+ def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
81
+ if self.help is None:
82
+ return
83
+
84
+ text = cleandoc(self.help).partition("\f")[0]
85
+ if not text:
86
+ return
87
+
88
+ formatter.write_paragraph()
89
+ with formatter.indentation():
90
+ formatter.write_text(text.split("\n\n", 1)[0] if ctx.parent is None else text)
91
+
92
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
93
+ self.format_usage(ctx, formatter)
94
+ self.format_help_text(ctx, formatter)
95
+ self._format_commands_grouped(ctx, formatter)
96
+ self.format_options(ctx, formatter)
97
+ self.format_epilog(ctx, formatter)
98
+
99
+ def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
100
+ _patched_format_options(self, ctx, formatter)
101
+
102
+ def _format_commands_grouped(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
103
+ command_key = (ctx.command_path or ctx.info_name or self.name or "").split()[-1]
104
+ groups_config = COMMAND_GROUPS.get(command_key, [])
105
+ if not groups_config:
106
+ self.format_commands(ctx, formatter)
107
+ return
108
+
109
+ all_names = [name for group in groups_config for name in group.get("commands", [])]
110
+ if not all_names:
111
+ return
112
+
113
+ col_width = max(len(name) for name in all_names) + 2
114
+
115
+ for group in groups_config:
116
+ name = group.get("name", "Commands")
117
+ commands = group.get("commands", [])
118
+ if not commands:
119
+ continue
120
+
121
+ formatter.write_heading(name)
122
+ for command_name in commands:
123
+ command = self.get_command(ctx, command_name)
124
+ if command is None or command.hidden:
125
+ continue
126
+ help_text = command.get_short_help_str(limit=formatter.width - col_width - 4)
127
+ formatter.write(f" {command_name:<{col_width}}{help_text}\n")
128
+
129
+
130
+ def _patched_format_options(
131
+ self: click.Command, ctx: click.Context, formatter: click.HelpFormatter
132
+ ) -> None:
133
+ """Render grouped options when configured for a command path."""
134
+ groups_config = OPTION_GROUPS.get(ctx.command_path, [])
135
+
136
+ if not groups_config:
137
+ options = []
138
+ for param in self.get_params(ctx):
139
+ record = param.get_help_record(ctx)
140
+ if record is not None:
141
+ options.append(record)
142
+ if options:
143
+ with formatter.section("Options"):
144
+ formatter.write_dl(options)
145
+ return
146
+
147
+ param_by_name: dict[str, click.Parameter] = {}
148
+ for param in self.get_params(ctx):
149
+ for opt in getattr(param, "opts", []):
150
+ param_by_name[opt] = param
151
+
152
+ used: set[int] = set()
153
+
154
+ for group in groups_config:
155
+ rows: list[tuple[str, str]] = []
156
+ for option_name in group.get("options", []):
157
+ param = param_by_name.get(option_name)
158
+ if param is None:
159
+ continue
160
+
161
+ param_id = id(param)
162
+ if param_id in used:
163
+ continue
164
+
165
+ used.add(param_id)
166
+ record = param.get_help_record(ctx)
167
+ if record is not None:
168
+ rows.append(record)
169
+
170
+ if rows:
171
+ with formatter.section(group.get("name", "Options")):
172
+ formatter.write_dl(rows)
173
+
174
+ remaining = []
175
+ for param in self.get_params(ctx):
176
+ if id(param) in used:
177
+ continue
178
+ record = param.get_help_record(ctx)
179
+ if record is not None:
180
+ remaining.append(record)
181
+
182
+ def _is_help_only(record: tuple[str, str]) -> bool:
183
+ opts = record[0] if isinstance(record, tuple) else str(record)
184
+ return "-h" in opts and "--help" in opts
185
+
186
+ non_help_remaining = [r for r in remaining if not _is_help_only(r)]
187
+ if non_help_remaining:
188
+ with formatter.section("Options"):
189
+ formatter.write_dl(non_help_remaining)
190
+
191
+
192
+ def install_option_groups_patch() -> None:
193
+ """Monkey-patch click.Command.format_options to support OPTION_GROUPS."""
194
+ click.Command.format_options = _patched_format_options # type: ignore[method-assign]
195
+ click.Context.formatter_class = FirecubeFormatter
@@ -0,0 +1,47 @@
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 click
18
+
19
+ from firecube.core.product.identity import ProductIdentity
20
+ from firecube.core.product.resolver import ProductResolver
21
+
22
+
23
+ def require_full_uri(value: str, *, option_name: str) -> None:
24
+ if value and "://" not in value:
25
+ raise click.UsageError(
26
+ f"{option_name} must be a full URI like 's3://bucket/path/x.zarr' or "
27
+ f"'file:///abs/path/x.zarr'. Bare names are no longer supported. "
28
+ f"Got: {value!r}"
29
+ )
30
+
31
+
32
+ def resolve_product_identity(
33
+ value: str,
34
+ *,
35
+ format: str,
36
+ product_name: str,
37
+ option_name: str = "--product",
38
+ ) -> ProductIdentity:
39
+ require_full_uri(value, option_name=option_name)
40
+ try:
41
+ return ProductResolver.resolve(value, format=format, product_name=product_name)
42
+ except ValueError as exc:
43
+ raise click.UsageError(str(exc)) from exc
44
+
45
+
46
+ def resolve_product_name(value: str, *, format: str = "zarr", product_name: str) -> str:
47
+ return resolve_product_identity(value, format=format, product_name=product_name).product_name
@@ -0,0 +1,123 @@
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
+ """Click rename-hint layer.
16
+
17
+ Augments :class:`click.NoSuchOption` errors with a navigation hint when the
18
+ user types a flag that was renamed in the strict-URI refactor.
19
+
20
+ This is NOT a deprecation alias - the old flag still errors out. It is a UX
21
+ layer that helps migrating users find the new flag name.
22
+
23
+ Click's native similarity-based "Did you mean ..." suggestion only fires when
24
+ the new flag is a close string match (e.g. ``--product`` -> ``--product-name``).
25
+ For semantic renames where the new name is unrelated (``--source`` ->
26
+ ``--input-data``), Click stays silent. This module fills that gap.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Final
32
+
33
+ import click
34
+
35
+ # Renames Click's similarity matcher will NOT auto-suggest.
36
+ # Layout: { old_flag: [ (command_path_prefix, new_flag_text, explanation), ... ] }
37
+ # Most specific prefix wins.
38
+ _RENAME_HINTS: Final[dict[str, list[tuple[tuple[str, ...], str, str]]]] = {
39
+ "--source": [
40
+ (
41
+ ("archive", "restore"),
42
+ "--archive (-a)",
43
+ "the .tgm artifact path was renamed to --archive in the strict-URI refactor",
44
+ ),
45
+ (
46
+ ("ingest",),
47
+ "--input-data (-i)",
48
+ "the plugin input flag was renamed in the strict-URI refactor; "
49
+ "--source is now reserved for product URIs (archive create only)",
50
+ ),
51
+ ],
52
+ "--target": [
53
+ (
54
+ ("archive", "create"),
55
+ "--archive (-a)",
56
+ "the .tgm artifact output was renamed to --archive; "
57
+ "--target now exclusively refers to product target URIs",
58
+ ),
59
+ ],
60
+ }
61
+
62
+
63
+ def build_command_path(ctx: click.Context | None) -> tuple[str, ...]:
64
+ """Build a dotted command path from a Click context chain.
65
+
66
+ Excludes the root context (whose ``info_name`` is the program name -
67
+ ``"firecube"`` in production, ``"cli"`` under :class:`click.testing.CliRunner`).
68
+ For ``firecube archive restore`` the returned tuple is ``("archive", "restore")``.
69
+ """
70
+ if ctx is None:
71
+ return ()
72
+ parts: list[str] = []
73
+ current: click.Context | None = ctx
74
+ while current is not None and current.parent is not None:
75
+ if current.info_name:
76
+ parts.append(current.info_name)
77
+ current = current.parent
78
+ return tuple(reversed(parts))
79
+
80
+
81
+ def get_rename_hint(option_name: str, command_path: tuple[str, ...]) -> str | None:
82
+ """Return a hint string for a renamed flag on a specific command, or ``None``.
83
+
84
+ Matches by longest command-path prefix - ``("archive", "restore")`` wins
85
+ over ``("archive",)`` if both registered.
86
+ """
87
+ candidates = _RENAME_HINTS.get(option_name)
88
+ if not candidates:
89
+ return None
90
+ # Sort by descending path length so the most specific match wins first.
91
+ for path_prefix, new_flag, explanation in sorted(candidates, key=lambda entry: -len(entry[0])):
92
+ if command_path[: len(path_prefix)] == path_prefix:
93
+ return f"Use {new_flag} instead ({explanation})."
94
+ return None
95
+
96
+
97
+ _INSTALLED_FLAG = "_firecube_rename_hints_installed"
98
+
99
+
100
+ def install_rename_hints() -> None:
101
+ """Monkey-patch :meth:`click.Command.parse_args` once to inject rename hints.
102
+
103
+ Subsequent calls are no-ops (idempotent). Safe to call from any module init.
104
+ """
105
+ if getattr(click.Command.parse_args, _INSTALLED_FLAG, False):
106
+ return
107
+
108
+ original_parse_args = click.Command.parse_args
109
+
110
+ def hinted_parse_args(self: click.Command, ctx: click.Context, args: list[str]) -> list[str]:
111
+ try:
112
+ return original_parse_args(self, ctx, args)
113
+ except click.NoSuchOption as exc:
114
+ command_path = build_command_path(ctx)
115
+ hint = get_rename_hint(exc.option_name, command_path)
116
+ if hint is not None:
117
+ # Inject hint into the message field. format_message() will pick it up
118
+ # alongside Click's own "Did you mean ...?" suggestion (if any).
119
+ object.__setattr__(exc, "message", f"{exc.message}\n\n Hint: {hint}")
120
+ raise
121
+
122
+ setattr(hinted_parse_args, _INSTALLED_FLAG, True)
123
+ click.Command.parse_args = hinted_parse_args # type: ignore[method-assign]
@@ -0,0 +1,247 @@
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 pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ import click
21
+
22
+ __all__ = [
23
+ "archive_uri_option",
24
+ "config_file_option",
25
+ "dry_run_flag",
26
+ "format_option",
27
+ "product_filter_option",
28
+ "product_name_option",
29
+ "product_uri_option",
30
+ "storage_driver_option",
31
+ "storage_type_option",
32
+ "target_uri_option",
33
+ "workspace_option",
34
+ "write_mode_option",
35
+ "yes_flag",
36
+ ]
37
+
38
+
39
+ def _uri_shell_complete(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[Any]:
40
+ from click.shell_completion import CompletionItem
41
+
42
+ return [CompletionItem(incomplete, type="file")]
43
+
44
+
45
+ def workspace_option(f: Any) -> Any:
46
+ return click.option(
47
+ "--workspace",
48
+ type=click.Path(path_type=Path),
49
+ help="workspace directory override",
50
+ )(f)
51
+
52
+
53
+ def config_file_option(f: Any) -> Any:
54
+ return click.option(
55
+ "--config-file",
56
+ type=click.Path(path_type=Path),
57
+ help="Firecube TOML config file (default: ~/.config/firecube/config.toml)",
58
+ )(f)
59
+
60
+
61
+ def storage_type_option(
62
+ f: Any = None,
63
+ *,
64
+ required: bool = True,
65
+ extra_help: str = "",
66
+ ) -> Any:
67
+ def decorator(func: Any) -> Any:
68
+ help_text = f"Storage locality/class. {extra_help}".strip()
69
+ return click.option(
70
+ "--storage-type",
71
+ "storage_type",
72
+ required=required,
73
+ type=click.Choice(["local", "s3"], case_sensitive=False),
74
+ help=help_text,
75
+ )(func)
76
+
77
+ if callable(f):
78
+ return decorator(f)
79
+ return decorator
80
+
81
+
82
+ def storage_driver_option(
83
+ f: Any = None,
84
+ *,
85
+ required: bool = True,
86
+ extra_help: str = "",
87
+ ) -> Any:
88
+ def decorator(func: Any) -> Any:
89
+ help_text = f"Storage backend driver. {extra_help}".strip()
90
+ return click.option(
91
+ "--storage-driver",
92
+ "storage_driver",
93
+ required=required,
94
+ type=click.Choice(["fsspec", "obstore"], case_sensitive=False),
95
+ help=help_text,
96
+ )(func)
97
+
98
+ if callable(f):
99
+ return decorator(f)
100
+ return decorator
101
+
102
+
103
+ def write_mode_option(
104
+ f: Any = None,
105
+ *,
106
+ required: bool = True,
107
+ extra_help: str = "",
108
+ ) -> Any:
109
+ def decorator(func: Any) -> Any:
110
+ help_text = (
111
+ f"Write strategy (staged=workspace-first, direct=stream to target). {extra_help}"
112
+ ).strip()
113
+ return click.option(
114
+ "-w",
115
+ "--write-mode",
116
+ "write_mode",
117
+ required=required,
118
+ type=click.Choice(["staged", "direct"], case_sensitive=False),
119
+ help=help_text,
120
+ )(func)
121
+
122
+ if callable(f):
123
+ return decorator(f)
124
+ return decorator
125
+
126
+
127
+ def product_uri_option(tier: Literal["write", "inspect"]) -> Any:
128
+ help_text = (
129
+ "Firecube product URI (file:///abs/path or s3://bucket/key); storage flags inferred from URI scheme."
130
+ if tier == "inspect"
131
+ else "Firecube product URI; --storage-type must match URI scheme."
132
+ )
133
+
134
+ def decorator(func: Any) -> Any:
135
+ return click.option(
136
+ "-p",
137
+ "--product",
138
+ "product",
139
+ required=True,
140
+ type=str,
141
+ help=help_text,
142
+ shell_complete=_uri_shell_complete,
143
+ )(func)
144
+
145
+ return decorator
146
+
147
+
148
+ def target_uri_option(tier: Literal["write", "inspect"]) -> Any:
149
+ help_text = (
150
+ "Target Firecube product URI (file:///abs/path or s3://bucket/key); storage flags inferred from URI scheme."
151
+ if tier == "inspect"
152
+ else "Target Firecube product URI; --storage-type must match URI scheme."
153
+ )
154
+
155
+ def decorator(func: Any) -> Any:
156
+ return click.option(
157
+ "-t",
158
+ "--target",
159
+ "target",
160
+ required=True,
161
+ type=str,
162
+ help=help_text,
163
+ shell_complete=_uri_shell_complete,
164
+ )(func)
165
+
166
+ return decorator
167
+
168
+
169
+ def archive_uri_option(tier: Literal["write", "inspect"]) -> Any:
170
+ """Shared -a, --archive URI flag for write and inspect commands."""
171
+ help_text = {
172
+ "write": "Target archive URI; runtime decides what is supported (file:///abs/path or s3://bucket/key).",
173
+ "inspect": "Archive URI to inspect (file:///abs/path or s3://bucket/key).",
174
+ }[tier]
175
+
176
+ def decorator(func: Any) -> Any:
177
+ return click.option(
178
+ "-a",
179
+ "--archive",
180
+ "archive",
181
+ type=str,
182
+ required=True,
183
+ help=help_text,
184
+ shell_complete=_uri_shell_complete,
185
+ )(func)
186
+
187
+ return decorator
188
+
189
+
190
+ def format_option(default: str = "table") -> Any:
191
+ def decorator(f: Any) -> Any:
192
+ return click.option(
193
+ "-f",
194
+ "--format",
195
+ "output_format",
196
+ type=click.Choice(["table", "json", "csv"], case_sensitive=False),
197
+ default=default,
198
+ show_default=True,
199
+ help="Output format.",
200
+ )(f)
201
+
202
+ return decorator
203
+
204
+
205
+ def product_filter_option(required: bool = False) -> Any:
206
+ def decorator(f: Any) -> Any:
207
+ return click.option(
208
+ "-n",
209
+ "--product-name",
210
+ "product_name",
211
+ required=required,
212
+ help="Filter by product name.",
213
+ )(f)
214
+
215
+ return decorator
216
+
217
+
218
+ def product_name_option(required: bool = True) -> Any:
219
+ def decorator(f: Any) -> Any:
220
+ return click.option(
221
+ "--product-name",
222
+ "product_name",
223
+ required=required,
224
+ help="Logical product name.",
225
+ )(f)
226
+
227
+ return decorator
228
+
229
+
230
+ def dry_run_flag(f: Any) -> Any:
231
+ return click.option(
232
+ "--dry-run",
233
+ "dry_run",
234
+ is_flag=True,
235
+ default=False,
236
+ help="Show what would happen without making any changes.",
237
+ )(f)
238
+
239
+
240
+ def yes_flag(f: Any) -> Any:
241
+ return click.option(
242
+ "--yes-i-really-mean-it",
243
+ "yes_i_really_mean_it",
244
+ is_flag=True,
245
+ default=False,
246
+ help="Skip confirmation prompts. Required for destructive operations in non-TTY.",
247
+ )(f)