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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- 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
|
firecube/cli/_product.py
ADDED
|
@@ -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)
|