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,376 @@
|
|
|
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 sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from firecube.core.errors import ManifestError
|
|
23
|
+
|
|
24
|
+
from ._common import confirm_deletion, parse_datetime, parse_meta_filters
|
|
25
|
+
from ._manager import resolve_cli_product, resolve_manager, storage_config_from_ctx
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.command(
|
|
29
|
+
"delete",
|
|
30
|
+
epilog="""\b
|
|
31
|
+
Examples:
|
|
32
|
+
# preview what would be deleted (always do this first)
|
|
33
|
+
firecube chunks delete --product-name <product> --dry-run
|
|
34
|
+
|
|
35
|
+
\b
|
|
36
|
+
# delete all chunks for a product
|
|
37
|
+
firecube chunks delete --product-name <product> --yes-i-really-mean-it
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
# delete chunks in a date range
|
|
41
|
+
firecube chunks delete --product-name <product> --range 2024-01-01,2024-03-31 --dry-run
|
|
42
|
+
|
|
43
|
+
\b
|
|
44
|
+
# preview deletions across all products
|
|
45
|
+
firecube chunks delete --all-products --dry-run
|
|
46
|
+
|
|
47
|
+
\b
|
|
48
|
+
# delete across all products (requires explicit confirmation)
|
|
49
|
+
firecube chunks delete --all-products --yes-i-really-mean-it
|
|
50
|
+
|
|
51
|
+
See also: firecube chunks list, firecube chunks delete-span,
|
|
52
|
+
firecube chunks snapshots rebuild
|
|
53
|
+
""",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--pattern", multiple=True, help="glob pattern to match chunk keys (can specify multiple)"
|
|
57
|
+
)
|
|
58
|
+
@click.option("-n", "--product-name", "product_name", help="Filter by product name.")
|
|
59
|
+
@click.option(
|
|
60
|
+
"--all-products",
|
|
61
|
+
"all_products",
|
|
62
|
+
is_flag=True,
|
|
63
|
+
default=False,
|
|
64
|
+
help="Apply to all products (mutually exclusive with --product-name).",
|
|
65
|
+
)
|
|
66
|
+
@click.option("--end-date", "end_date", help="delete chunks created before date (YYYY-MM-DD)")
|
|
67
|
+
@click.option("--start-date", "start_date", help="delete chunks created after date (YYYY-MM-DD)")
|
|
68
|
+
@click.option("--range", "date_range", help="delete chunks in date range (YYYY-MM-DD,YYYY-MM-DD)")
|
|
69
|
+
@click.option("--type", "chunk_type", help="filter by chunk type (chunk, meta)")
|
|
70
|
+
@click.option(
|
|
71
|
+
"--meta",
|
|
72
|
+
"meta_filters",
|
|
73
|
+
multiple=True,
|
|
74
|
+
help="filter by tracked chunk metadata key=value (value may be JSON)",
|
|
75
|
+
)
|
|
76
|
+
@click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
|
|
77
|
+
@click.option(
|
|
78
|
+
"--manifest-only", is_flag=True, help="remove tracked chunk records only, keep storage files"
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--storage-only", is_flag=True, help="delete from storage only, keep tracked chunk records"
|
|
82
|
+
)
|
|
83
|
+
@click.option("--yes-i-really-mean-it", is_flag=True, help="skip confirmation prompts")
|
|
84
|
+
@click.option("--dry-run", is_flag=True, help="show what would be deleted without doing it")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--include-metadata",
|
|
87
|
+
is_flag=True,
|
|
88
|
+
help="include metadata chunks (zarr.json, etc.) - DANGEROUS!",
|
|
89
|
+
)
|
|
90
|
+
@click.pass_context
|
|
91
|
+
def delete_cmd(
|
|
92
|
+
ctx: click.Context,
|
|
93
|
+
pattern,
|
|
94
|
+
product_name,
|
|
95
|
+
all_products: bool,
|
|
96
|
+
end_date,
|
|
97
|
+
start_date,
|
|
98
|
+
date_range,
|
|
99
|
+
chunk_type,
|
|
100
|
+
meta_filters,
|
|
101
|
+
workspace: Path | None,
|
|
102
|
+
manifest_only: bool,
|
|
103
|
+
storage_only: bool,
|
|
104
|
+
yes_i_really_mean_it: bool,
|
|
105
|
+
dry_run: bool,
|
|
106
|
+
include_metadata: bool,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""delete chunks and storage data
|
|
109
|
+
|
|
110
|
+
delete chunk records and storage data tracked in the .firecube/ control
|
|
111
|
+
plane. CAUTION: this operation is destructive and cannot be undone -- it
|
|
112
|
+
removes both control-plane records and the underlying storage files. use
|
|
113
|
+
--dry-run to preview deletions before committing"""
|
|
114
|
+
if product_name and all_products:
|
|
115
|
+
raise click.UsageError(
|
|
116
|
+
"ConflictingScope: --product-name and --all-products are mutually exclusive. "
|
|
117
|
+
"Provide exactly one."
|
|
118
|
+
)
|
|
119
|
+
if not product_name and not all_products:
|
|
120
|
+
raise click.UsageError(
|
|
121
|
+
"MissingScope: provide --product-name PRODUCT to target a specific product, "
|
|
122
|
+
"or --all-products to target all products."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if all_products and not dry_run and not yes_i_really_mean_it:
|
|
126
|
+
if not sys.stdin.isatty():
|
|
127
|
+
raise click.UsageError(
|
|
128
|
+
"Confirmation required: --all-products deletes across all products. "
|
|
129
|
+
"Re-run with --yes-i-really-mean-it or run interactively."
|
|
130
|
+
)
|
|
131
|
+
click.confirm("Delete chunks across ALL products?", abort=True)
|
|
132
|
+
|
|
133
|
+
if manifest_only and storage_only:
|
|
134
|
+
raise click.ClickException("Cannot specify both --manifest-only and --storage-only")
|
|
135
|
+
|
|
136
|
+
if include_metadata and not yes_i_really_mean_it:
|
|
137
|
+
raise click.ClickException(
|
|
138
|
+
"Metadata deletion requested. Re-run with --yes-i-really-mean-it to continue."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
product_name, product_uri = resolve_cli_product(product_name)
|
|
142
|
+
manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
|
|
143
|
+
meta = parse_meta_filters(meta_filters) if meta_filters else None
|
|
144
|
+
|
|
145
|
+
before_dt = parse_datetime(end_date)
|
|
146
|
+
after_dt = parse_datetime(start_date)
|
|
147
|
+
if date_range:
|
|
148
|
+
try:
|
|
149
|
+
start_str, end_str = date_range.split(",", 1)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise click.ClickException(
|
|
152
|
+
"Invalid date range format. Use YYYY-MM-DD,YYYY-MM-DD"
|
|
153
|
+
) from exc
|
|
154
|
+
after_dt = parse_datetime(start_str.strip())
|
|
155
|
+
before_dt = parse_datetime(end_str.strip())
|
|
156
|
+
|
|
157
|
+
plans = []
|
|
158
|
+
patterns = pattern or [None]
|
|
159
|
+
for p in patterns:
|
|
160
|
+
plan = manager.create_deletion_plan(
|
|
161
|
+
pattern=p,
|
|
162
|
+
product=product_name,
|
|
163
|
+
before=before_dt,
|
|
164
|
+
after=after_dt,
|
|
165
|
+
chunk_type=chunk_type,
|
|
166
|
+
include_metadata=include_metadata,
|
|
167
|
+
meta=meta,
|
|
168
|
+
)
|
|
169
|
+
if plan.chunks:
|
|
170
|
+
plans.append(plan)
|
|
171
|
+
|
|
172
|
+
if not plans:
|
|
173
|
+
click.echo("No chunks found matching criteria.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
all_chunks = []
|
|
177
|
+
affected_products = set()
|
|
178
|
+
all_manifests = set()
|
|
179
|
+
for plan in plans:
|
|
180
|
+
all_chunks.extend(plan.chunks)
|
|
181
|
+
affected_products.update(plan.products_affected)
|
|
182
|
+
all_manifests.update(plan.manifest_files)
|
|
183
|
+
|
|
184
|
+
seen = set()
|
|
185
|
+
unique_chunks = []
|
|
186
|
+
for chunk in all_chunks:
|
|
187
|
+
key = (chunk.product, chunk.key)
|
|
188
|
+
if key not in seen:
|
|
189
|
+
seen.add(key)
|
|
190
|
+
unique_chunks.append(chunk)
|
|
191
|
+
|
|
192
|
+
combined_plan = manager.create_deletion_plan()
|
|
193
|
+
combined_plan.chunks = unique_chunks
|
|
194
|
+
combined_plan.total_size = sum(chunk.size for chunk in unique_chunks)
|
|
195
|
+
combined_plan.products_affected = affected_products
|
|
196
|
+
combined_plan.manifest_files = all_manifests
|
|
197
|
+
|
|
198
|
+
storage_cfg = None
|
|
199
|
+
if not manifest_only:
|
|
200
|
+
storage_cfg = storage_config_from_ctx(ctx)
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
not yes_i_really_mean_it
|
|
204
|
+
and not dry_run
|
|
205
|
+
and not confirm_deletion(
|
|
206
|
+
combined_plan, manifest_only=manifest_only, storage_only=storage_only
|
|
207
|
+
)
|
|
208
|
+
):
|
|
209
|
+
click.echo("Aborted.")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
result = manager.execute_deletion(
|
|
213
|
+
combined_plan,
|
|
214
|
+
delete_storage=not manifest_only,
|
|
215
|
+
delete_manifest=not storage_only,
|
|
216
|
+
storage_config=storage_cfg,
|
|
217
|
+
dry_run=dry_run,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if dry_run:
|
|
221
|
+
click.echo("\nDRY RUN - Would delete:")
|
|
222
|
+
click.echo(f" Chunks: {result['would_delete_chunks']:,}")
|
|
223
|
+
bytes_count = result["would_delete_size_bytes"]
|
|
224
|
+
if bytes_count > 0:
|
|
225
|
+
size_mb = bytes_count / (1024 * 1024)
|
|
226
|
+
size_gb = bytes_count / (1024 * 1024 * 1024)
|
|
227
|
+
click.echo(f" Size: {size_gb:.1f} GB" if size_gb >= 1 else f" Size: {size_mb:.1f} MB")
|
|
228
|
+
products = result.get("products_affected", [])
|
|
229
|
+
if products:
|
|
230
|
+
click.echo(f" Products: {', '.join(products)}")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
click.echo(f"\nDeleted {result['deleted_chunks']:,} chunks")
|
|
234
|
+
bytes_count = result["deleted_size_bytes"]
|
|
235
|
+
if bytes_count > 0:
|
|
236
|
+
size_mb = bytes_count / (1024 * 1024)
|
|
237
|
+
size_gb = bytes_count / (1024 * 1024 * 1024)
|
|
238
|
+
click.echo(f"Size: {size_gb:.1f} GB" if size_gb >= 1 else f"Size: {size_mb:.1f} MB")
|
|
239
|
+
|
|
240
|
+
if result["storage_errors"]:
|
|
241
|
+
click.echo(f"\nStorage errors: {len(result['storage_errors'])}")
|
|
242
|
+
for error in result["storage_errors"][:5]:
|
|
243
|
+
click.echo(f" - {error}")
|
|
244
|
+
|
|
245
|
+
if result["manifest_errors"]:
|
|
246
|
+
click.echo(f"\nManifest errors: {len(result['manifest_errors'])}")
|
|
247
|
+
for error in result["manifest_errors"]:
|
|
248
|
+
click.echo(f" - {error}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@click.command(
|
|
252
|
+
"delete-span",
|
|
253
|
+
epilog="""\b
|
|
254
|
+
Examples:
|
|
255
|
+
# preview span deletion for a product
|
|
256
|
+
firecube chunks delete-span -n <product> --dry-run
|
|
257
|
+
|
|
258
|
+
\b
|
|
259
|
+
# delete spans for a specific run
|
|
260
|
+
firecube chunks delete-span -n <product> --run-id <run-id> --yes-i-really-mean-it
|
|
261
|
+
|
|
262
|
+
\b
|
|
263
|
+
# force-delete non-aligned spans
|
|
264
|
+
firecube chunks delete-span -n <product> --run-id <run-id> --force --yes-i-really-mean-it
|
|
265
|
+
|
|
266
|
+
See also: firecube chunks delete, firecube chunks runs list
|
|
267
|
+
""",
|
|
268
|
+
)
|
|
269
|
+
@click.option(
|
|
270
|
+
"-n", "--product-name", "product_name", required=True, help="target product (e.g. product.zarr)"
|
|
271
|
+
)
|
|
272
|
+
@click.option("--run-id", help="filter span records by run_id")
|
|
273
|
+
@click.option("--batch-id", help="filter span records by batch_id")
|
|
274
|
+
@click.option("-g", "--group", "group_name", help="filter by group (e.g. F120)")
|
|
275
|
+
@click.option(
|
|
276
|
+
"--meta",
|
|
277
|
+
"meta_filters",
|
|
278
|
+
multiple=True,
|
|
279
|
+
help="filter by tracked span metadata key=value (value may be JSON)",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--include-replaced",
|
|
283
|
+
is_flag=True,
|
|
284
|
+
help="include spans already marked replaced (useful to retry deletions after a partial failure)",
|
|
285
|
+
)
|
|
286
|
+
@click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
|
|
287
|
+
@click.option(
|
|
288
|
+
"--time-dim",
|
|
289
|
+
"time_dim",
|
|
290
|
+
help=(
|
|
291
|
+
"time dimension name for cubes written with a custom time_dim_name; "
|
|
292
|
+
"only needed when span records predate dim recording and the cube has "
|
|
293
|
+
"no timestamp-state array (must match the cube layout)"
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
@click.option("--dry-run", is_flag=True, help="show what would be deleted without doing it")
|
|
297
|
+
@click.option("--force", is_flag=True, help="allow deletion even when spans are not chunk-aligned")
|
|
298
|
+
@click.option("--yes-i-really-mean-it", is_flag=True, help="skip confirmation prompts")
|
|
299
|
+
@click.pass_context
|
|
300
|
+
def delete_span_cmd(
|
|
301
|
+
ctx: click.Context,
|
|
302
|
+
product_name: str,
|
|
303
|
+
run_id: str | None,
|
|
304
|
+
batch_id: str | None,
|
|
305
|
+
group_name: str | None,
|
|
306
|
+
meta_filters,
|
|
307
|
+
include_replaced: bool,
|
|
308
|
+
workspace: Path | None,
|
|
309
|
+
time_dim: str | None,
|
|
310
|
+
dry_run: bool,
|
|
311
|
+
force: bool,
|
|
312
|
+
yes_i_really_mean_it: bool,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""delete storage chunks from a span
|
|
315
|
+
|
|
316
|
+
delete storage chunks described by specific tracked span records.
|
|
317
|
+
removes data written as part of a particular ingestion span or run.
|
|
318
|
+
CAUTION: data removed from storage cannot be recovered -- use --dry-run
|
|
319
|
+
first"""
|
|
320
|
+
product_name, product_uri = resolve_cli_product(product_name)
|
|
321
|
+
manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
|
|
322
|
+
meta = parse_meta_filters(meta_filters) if meta_filters else {}
|
|
323
|
+
if run_id:
|
|
324
|
+
meta["run_id"] = run_id
|
|
325
|
+
if batch_id:
|
|
326
|
+
meta["batch_id"] = batch_id
|
|
327
|
+
if group_name:
|
|
328
|
+
meta["group"] = group_name
|
|
329
|
+
|
|
330
|
+
spans = manager.list_chunks(
|
|
331
|
+
product=product_name,
|
|
332
|
+
chunk_type="span",
|
|
333
|
+
meta=meta or None,
|
|
334
|
+
include_replaced=include_replaced,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if not spans:
|
|
338
|
+
click.echo("No span records found matching criteria.")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
if not yes_i_really_mean_it and not dry_run:
|
|
342
|
+
click.echo(
|
|
343
|
+
f"About to delete chunks for {len(spans):,} span records (product={product_name})."
|
|
344
|
+
)
|
|
345
|
+
if not click.confirm("Continue?"):
|
|
346
|
+
click.echo("Aborted.")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
result = manager.delete_spans(
|
|
351
|
+
spans,
|
|
352
|
+
dry_run=dry_run,
|
|
353
|
+
force=force,
|
|
354
|
+
update_manifest=not dry_run,
|
|
355
|
+
update_state=not dry_run,
|
|
356
|
+
time_dim_name=time_dim,
|
|
357
|
+
)
|
|
358
|
+
except (ManifestError, ValueError) as exc:
|
|
359
|
+
raise click.ClickException(str(exc)) from exc
|
|
360
|
+
|
|
361
|
+
if dry_run:
|
|
362
|
+
click.echo(
|
|
363
|
+
f"DRY RUN: would delete {result.get('deleted_keys', 0):,} chunk keys from storage "
|
|
364
|
+
f"across {result.get('deleted_spans', 0):,} spans"
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
click.echo(
|
|
368
|
+
f"Deleted {result.get('deleted_keys', 0):,} chunk keys from storage across "
|
|
369
|
+
f"{result.get('deleted_spans', 0):,} spans"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
errors = result.get("errors") or []
|
|
373
|
+
if errors:
|
|
374
|
+
click.echo(f"\nErrors: {len(errors)}")
|
|
375
|
+
for err in errors[:10]:
|
|
376
|
+
click.echo(f" - {err}")
|
|
@@ -0,0 +1,334 @@
|
|
|
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 json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from ._common import parse_datetime, parse_meta_filters, parse_time_range
|
|
23
|
+
from ._manager import resolve_cli_product, resolve_manager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command(
|
|
27
|
+
"list",
|
|
28
|
+
epilog="""\b
|
|
29
|
+
Examples:
|
|
30
|
+
# list all chunks across all products
|
|
31
|
+
firecube chunks list
|
|
32
|
+
|
|
33
|
+
\b
|
|
34
|
+
# list chunks for a specific product
|
|
35
|
+
firecube chunks list --product-name <product>
|
|
36
|
+
|
|
37
|
+
\b
|
|
38
|
+
# filter by date range
|
|
39
|
+
firecube chunks list --start-date 2024-01-01 --end-date 2024-06-30
|
|
40
|
+
|
|
41
|
+
\b
|
|
42
|
+
# output as JSON for scripting
|
|
43
|
+
firecube chunks list --product-name <product> -f json
|
|
44
|
+
|
|
45
|
+
See also: firecube chunks delete, firecube chunks runs list,
|
|
46
|
+
firecube chunks snapshots status
|
|
47
|
+
""",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--pattern", multiple=True, help="glob pattern to match chunk keys (can specify multiple)"
|
|
51
|
+
)
|
|
52
|
+
@click.option("-n", "--product-name", "product_name", help="filter by product name")
|
|
53
|
+
@click.option("--end-date", "end_date", help="show chunks created before date (YYYY-MM-DD)")
|
|
54
|
+
@click.option("--start-date", "start_date", help="show chunks created after date (YYYY-MM-DD)")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--time-range",
|
|
57
|
+
"time_range",
|
|
58
|
+
default=None,
|
|
59
|
+
help="filter spans by time range START:END (ISO8601); matches spans overlapping the window",
|
|
60
|
+
)
|
|
61
|
+
@click.option("--type", "chunk_type", help="filter by chunk type (chunk, meta)")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--meta",
|
|
64
|
+
"meta_filters",
|
|
65
|
+
multiple=True,
|
|
66
|
+
help="filter by tracked chunk metadata key=value (value may be JSON)",
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"--workspace",
|
|
70
|
+
type=click.Path(path_type=Path),
|
|
71
|
+
help="workspace directory override (local path)",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"-f",
|
|
75
|
+
"--format",
|
|
76
|
+
"output_format",
|
|
77
|
+
type=click.Choice(["table", "json", "csv"]),
|
|
78
|
+
default="table",
|
|
79
|
+
help="output format",
|
|
80
|
+
)
|
|
81
|
+
@click.option("--limit", type=int, help="limit number of results")
|
|
82
|
+
@click.option(
|
|
83
|
+
"--include-span",
|
|
84
|
+
is_flag=True,
|
|
85
|
+
help="include span coverage payload (time_index_ranges, arrays, alignment) in output",
|
|
86
|
+
)
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def list_cmd(
|
|
89
|
+
ctx: click.Context,
|
|
90
|
+
pattern,
|
|
91
|
+
product_name,
|
|
92
|
+
end_date,
|
|
93
|
+
start_date,
|
|
94
|
+
time_range,
|
|
95
|
+
chunk_type,
|
|
96
|
+
meta_filters,
|
|
97
|
+
workspace,
|
|
98
|
+
output_format,
|
|
99
|
+
limit,
|
|
100
|
+
include_span,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""list tracked chunk records
|
|
103
|
+
|
|
104
|
+
list chunk records tracked in the .firecube/ control plane for one or
|
|
105
|
+
all products. filter by product, date range, span, or chunk type.
|
|
106
|
+
supports table and JSON output for scripting"""
|
|
107
|
+
product_name, product_uri = resolve_cli_product(product_name)
|
|
108
|
+
manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
|
|
109
|
+
|
|
110
|
+
before_dt = parse_datetime(end_date)
|
|
111
|
+
after_dt = parse_datetime(start_date)
|
|
112
|
+
time_range_dt = parse_time_range(time_range)
|
|
113
|
+
meta = parse_meta_filters(meta_filters) if meta_filters else None
|
|
114
|
+
|
|
115
|
+
if pattern:
|
|
116
|
+
chunks = []
|
|
117
|
+
for p in pattern:
|
|
118
|
+
entries = manager.list_chunks(
|
|
119
|
+
pattern=p,
|
|
120
|
+
product=product_name,
|
|
121
|
+
before=before_dt,
|
|
122
|
+
after=after_dt,
|
|
123
|
+
chunk_type=chunk_type,
|
|
124
|
+
meta=meta,
|
|
125
|
+
time_overlaps=time_range_dt,
|
|
126
|
+
)
|
|
127
|
+
chunks.extend(entries)
|
|
128
|
+
deduped = []
|
|
129
|
+
seen = set()
|
|
130
|
+
for chunk in chunks:
|
|
131
|
+
key = (chunk.product, chunk.key)
|
|
132
|
+
if key not in seen:
|
|
133
|
+
seen.add(key)
|
|
134
|
+
deduped.append(chunk)
|
|
135
|
+
all_chunks = deduped
|
|
136
|
+
else:
|
|
137
|
+
all_chunks = manager.list_chunks(
|
|
138
|
+
product=product_name,
|
|
139
|
+
before=before_dt,
|
|
140
|
+
after=after_dt,
|
|
141
|
+
chunk_type=chunk_type,
|
|
142
|
+
meta=meta,
|
|
143
|
+
time_overlaps=time_range_dt,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if limit:
|
|
147
|
+
all_chunks = all_chunks[:limit]
|
|
148
|
+
|
|
149
|
+
def _span_payload(chunk) -> dict | None:
|
|
150
|
+
if not include_span:
|
|
151
|
+
return None
|
|
152
|
+
if not isinstance(chunk.record, dict):
|
|
153
|
+
return None
|
|
154
|
+
span = chunk.record.get("span")
|
|
155
|
+
if not isinstance(span, dict):
|
|
156
|
+
return None
|
|
157
|
+
return span
|
|
158
|
+
|
|
159
|
+
def _span_summary(chunk) -> str:
|
|
160
|
+
_ = _span_payload(chunk)
|
|
161
|
+
return str(chunk.timestamps_written) if chunk.timestamps_written else ""
|
|
162
|
+
|
|
163
|
+
if output_format == "json":
|
|
164
|
+
data = [
|
|
165
|
+
{
|
|
166
|
+
"product": chunk.product,
|
|
167
|
+
"key": chunk.key,
|
|
168
|
+
"type": chunk.chunk_type,
|
|
169
|
+
"size": chunk.size,
|
|
170
|
+
"size_mb": round(chunk.size_mb, 2),
|
|
171
|
+
"timestamp": chunk.timestamp,
|
|
172
|
+
"datetime": chunk.datetime.isoformat(),
|
|
173
|
+
"manifest": str(chunk.manifest_path),
|
|
174
|
+
"meta": chunk.meta,
|
|
175
|
+
**({"span": span} if (span := _span_payload(chunk)) else {}),
|
|
176
|
+
}
|
|
177
|
+
for chunk in all_chunks
|
|
178
|
+
]
|
|
179
|
+
click.echo(json.dumps(data, indent=2))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if output_format == "csv":
|
|
183
|
+
if include_span:
|
|
184
|
+
click.echo("product,key,type,size,size_mb,datetime,manifest,span")
|
|
185
|
+
else:
|
|
186
|
+
click.echo("product,key,type,size,size_mb,datetime,manifest")
|
|
187
|
+
for chunk in all_chunks:
|
|
188
|
+
if include_span:
|
|
189
|
+
span_csv = json.dumps(_span_payload(chunk) or {}, separators=(",", ":"))
|
|
190
|
+
click.echo(
|
|
191
|
+
f"{chunk.product},{chunk.key},{chunk.chunk_type},{chunk.size},"
|
|
192
|
+
f"{chunk.size_mb:.2f},{chunk.datetime.isoformat()},{chunk.manifest_path},{span_csv}"
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
click.echo(
|
|
196
|
+
f"{chunk.product},{chunk.key},{chunk.chunk_type},{chunk.size},"
|
|
197
|
+
f"{chunk.size_mb:.2f},{chunk.datetime.isoformat()},{chunk.manifest_path}"
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if not all_chunks:
|
|
202
|
+
click.echo("No chunks found matching criteria.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
sample = all_chunks[:50]
|
|
206
|
+
max_product = max(len(chunk.product) for chunk in sample)
|
|
207
|
+
max_key = min(max(len(chunk.key) for chunk in sample), 60)
|
|
208
|
+
|
|
209
|
+
def _meta_str(c) -> str:
|
|
210
|
+
if not c.meta:
|
|
211
|
+
return ""
|
|
212
|
+
try:
|
|
213
|
+
return json.dumps(c.meta, separators=(",", ":"))
|
|
214
|
+
except TypeError:
|
|
215
|
+
return str(c.meta)
|
|
216
|
+
|
|
217
|
+
meta_samples = [_meta_str(c) for c in sample]
|
|
218
|
+
max_meta = 0
|
|
219
|
+
if any(meta_samples):
|
|
220
|
+
max_meta = min(max(len(m) for m in meta_samples), 40)
|
|
221
|
+
|
|
222
|
+
max_span = 0
|
|
223
|
+
if include_span:
|
|
224
|
+
span_samples = [_span_summary(c) for c in sample]
|
|
225
|
+
if any(span_samples):
|
|
226
|
+
max_span = min(max(len(s) for s in span_samples), 36)
|
|
227
|
+
|
|
228
|
+
if max_meta > 0 and max_span > 0:
|
|
229
|
+
click.echo(
|
|
230
|
+
f"{'Product':<{max_product}} "
|
|
231
|
+
f"{'Key':<{max_key}} "
|
|
232
|
+
f"{'Type':<6} "
|
|
233
|
+
f"{'Size (MB)':<10} "
|
|
234
|
+
f"{'Date':<19} "
|
|
235
|
+
f"{'Meta':<{max_meta}} "
|
|
236
|
+
f"{'Span':<{max_span}}"
|
|
237
|
+
)
|
|
238
|
+
underline_len = max_product + max_key + 6 + 10 + 19 + max_meta + max_span + 6
|
|
239
|
+
click.echo("-" * underline_len)
|
|
240
|
+
elif max_meta > 0:
|
|
241
|
+
click.echo(
|
|
242
|
+
f"{'Product':<{max_product}} "
|
|
243
|
+
f"{'Key':<{max_key}} "
|
|
244
|
+
f"{'Type':<6} "
|
|
245
|
+
f"{'Size (MB)':<10} "
|
|
246
|
+
f"{'Date':<19} "
|
|
247
|
+
f"{'Meta':<{max_meta}}"
|
|
248
|
+
)
|
|
249
|
+
underline_len = max_product + max_key + 6 + 10 + 19 + max_meta + 5
|
|
250
|
+
click.echo("-" * underline_len)
|
|
251
|
+
elif max_span > 0:
|
|
252
|
+
click.echo(
|
|
253
|
+
f"{'Product':<{max_product}} "
|
|
254
|
+
f"{'Key':<{max_key}} "
|
|
255
|
+
f"{'Type':<6} "
|
|
256
|
+
f"{'Size (MB)':<10} "
|
|
257
|
+
f"{'Date':<19} "
|
|
258
|
+
f"{'Span':<{max_span}}"
|
|
259
|
+
)
|
|
260
|
+
underline_len = max_product + max_key + 6 + 10 + 19 + max_span + 5
|
|
261
|
+
click.echo("-" * underline_len)
|
|
262
|
+
else:
|
|
263
|
+
click.echo(
|
|
264
|
+
f"{'Product':<{max_product}} "
|
|
265
|
+
f"{'Key':<{max_key}} "
|
|
266
|
+
f"{'Type':<6} "
|
|
267
|
+
f"{'Size (MB)':<10} "
|
|
268
|
+
f"{'Date':<19}"
|
|
269
|
+
)
|
|
270
|
+
underline_len = max_product + max_key + 6 + 10 + 19 + 4
|
|
271
|
+
click.echo("-" * underline_len)
|
|
272
|
+
|
|
273
|
+
for chunk in all_chunks:
|
|
274
|
+
key_display = chunk.key if len(chunk.key) <= max_key else f"{chunk.key[: max_key - 3]}..."
|
|
275
|
+
size_mb = f"{chunk.size_mb:.1f}"
|
|
276
|
+
date_str = chunk.datetime.strftime("%Y-%m-%d %H:%M:%S")
|
|
277
|
+
span_text = _span_summary(chunk)
|
|
278
|
+
if len(span_text) > max_span and max_span > 3:
|
|
279
|
+
span_display = span_text[: max_span - 3] + "..."
|
|
280
|
+
else:
|
|
281
|
+
span_display = span_text
|
|
282
|
+
|
|
283
|
+
if max_meta > 0 and max_span > 0:
|
|
284
|
+
meta_text = _meta_str(chunk)
|
|
285
|
+
if len(meta_text) > max_meta and max_meta > 3:
|
|
286
|
+
meta_display = meta_text[: max_meta - 3] + "..."
|
|
287
|
+
else:
|
|
288
|
+
meta_display = meta_text
|
|
289
|
+
click.echo(
|
|
290
|
+
f"{chunk.product:<{max_product}} "
|
|
291
|
+
f"{key_display:<{max_key}} "
|
|
292
|
+
f"{chunk.chunk_type:<6} "
|
|
293
|
+
f"{size_mb:<10} "
|
|
294
|
+
f"{date_str:<19} "
|
|
295
|
+
f"{meta_display:<{max_meta}} "
|
|
296
|
+
f"{span_display:<{max_span}}"
|
|
297
|
+
)
|
|
298
|
+
elif max_meta > 0:
|
|
299
|
+
meta_text = _meta_str(chunk)
|
|
300
|
+
if len(meta_text) > max_meta and max_meta > 3:
|
|
301
|
+
meta_display = meta_text[: max_meta - 3] + "..."
|
|
302
|
+
else:
|
|
303
|
+
meta_display = meta_text
|
|
304
|
+
click.echo(
|
|
305
|
+
f"{chunk.product:<{max_product}} "
|
|
306
|
+
f"{key_display:<{max_key}} "
|
|
307
|
+
f"{chunk.chunk_type:<6} "
|
|
308
|
+
f"{size_mb:<10} "
|
|
309
|
+
f"{date_str:<19} "
|
|
310
|
+
f"{meta_display:<{max_meta}}"
|
|
311
|
+
)
|
|
312
|
+
elif max_span > 0:
|
|
313
|
+
click.echo(
|
|
314
|
+
f"{chunk.product:<{max_product}} "
|
|
315
|
+
f"{key_display:<{max_key}} "
|
|
316
|
+
f"{chunk.chunk_type:<6} "
|
|
317
|
+
f"{size_mb:<10} "
|
|
318
|
+
f"{date_str:<19} "
|
|
319
|
+
f"{span_display:<{max_span}}"
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
click.echo(
|
|
323
|
+
f"{chunk.product:<{max_product}} "
|
|
324
|
+
f"{key_display:<{max_key}} "
|
|
325
|
+
f"{chunk.chunk_type:<6} "
|
|
326
|
+
f"{size_mb:<10} "
|
|
327
|
+
f"{date_str}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
total_size_mb = sum(chunk.size_mb for chunk in all_chunks)
|
|
331
|
+
products = {chunk.product for chunk in all_chunks}
|
|
332
|
+
click.echo(f"\nSummary: {len(all_chunks):,} chunks, {total_size_mb:.1f} MB total")
|
|
333
|
+
if len(products) > 1:
|
|
334
|
+
click.echo(f"Products: {', '.join(sorted(products))}")
|