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,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))}")