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,161 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ from pathlib import Path
20
+ from typing import overload
21
+
22
+ import click
23
+
24
+ from firecube.cli._ctx import get_config, get_storage_config
25
+ from firecube.cli._product import resolve_product_identity
26
+ from firecube.core.config import StorageConfig
27
+ from firecube.core.controlplane import ChunkManager
28
+ from firecube.core.product.identity import ProductIdentity
29
+ from firecube.core.runtime import identity_from_storage_config
30
+ from firecube.core.storage.binding import StorageBinding
31
+ from firecube.core.storage.driver_config import StorageDriverConfig
32
+ from firecube.core.storage.uri import StorageUri
33
+
34
+ log = logging.getLogger("firecube.cli")
35
+
36
+
37
+ def resolve_manager(
38
+ ctx: click.Context,
39
+ workspace: Path | None,
40
+ product_uri: StorageUri | None = None,
41
+ *,
42
+ product_name: str | None = None,
43
+ ) -> ChunkManager:
44
+ """Resolve a ChunkManager for chunk operations using shared config + overrides."""
45
+ ctx.ensure_object(dict)
46
+ base_workspace = workspace if workspace is not None else ctx.obj.get("workspace")
47
+ cfg = get_config(ctx)
48
+ storage_config: StorageConfig | None = None
49
+ env_storage_type_present = bool(os.environ.get("FIRECUBE_STORAGE_TYPE"))
50
+ if cfg.get("storage") or env_storage_type_present:
51
+ storage_config = get_storage_config(ctx, set_global=False)
52
+
53
+ if product_uri is not None and product_name is not None:
54
+ driver = StorageDriverConfig.from_storage_config_or_default(storage_config)
55
+ binding = StorageBinding(
56
+ identity=ProductIdentity.from_uri(
57
+ uri=product_uri,
58
+ format="zarr",
59
+ product_name=product_name,
60
+ ),
61
+ driver=driver,
62
+ )
63
+ manager = ChunkManager(binding=binding, workspace=base_workspace)
64
+ ctx.call_on_close(manager.close)
65
+ log.info("Chunks using product URI target (product_uri=%s)", manager.base_uri)
66
+ return manager
67
+
68
+ base_output = None
69
+ if product_uri is not None:
70
+ base_output = product_uri.to_str()
71
+
72
+ if base_output is None and storage_config is not None:
73
+ base_uri_from_config = _base_uri_from_storage_config(storage_config)
74
+ base_output = base_uri_from_config.to_str() if base_uri_from_config is not None else None
75
+
76
+ if base_output is None:
77
+ msg = (
78
+ "Chunk operations require a full product URI or a [storage] configuration in config.toml "
79
+ "(no product URI or storage configuration found)."
80
+ )
81
+ log.error(msg)
82
+ raise click.ClickException(msg)
83
+
84
+ base_uri = _storage_uri_from_base(base_output)
85
+ driver = StorageDriverConfig.from_storage_config_or_default(storage_config)
86
+ binding = StorageBinding(
87
+ identity=ProductIdentity.from_uri(
88
+ base_uri.join("__firecube_controlplane__"),
89
+ "zarr",
90
+ product_name="__firecube_controlplane__",
91
+ ),
92
+ driver=driver,
93
+ )
94
+ manager = ChunkManager(binding=binding, workspace=base_workspace)
95
+ ctx.call_on_close(manager.close)
96
+
97
+ if storage_config is not None:
98
+ config_identity = identity_from_storage_config(storage_config)
99
+ bucket = config_identity.product_uri.authority if config_identity is not None else None
100
+ log.info(
101
+ "Chunks using storage configuration (storage_type=%s, bucket=%s, base_uri=%s)",
102
+ storage_config.storage_type,
103
+ bucket,
104
+ manager.base_uri,
105
+ )
106
+ if not ctx.obj.get("chunks_quiet"):
107
+ source = "env" if env_storage_type_present else "config"
108
+ click.echo(
109
+ f"[chunks] storage: type={storage_config.storage_type} "
110
+ f"base_uri={manager.base_uri} (from {source})",
111
+ err=True,
112
+ )
113
+ else:
114
+ log.info("Chunks using product URI base (base_uri=%s)", manager.base_uri)
115
+
116
+ return manager
117
+
118
+
119
+ def _storage_uri_from_base(raw: str) -> StorageUri:
120
+ if "://" not in raw:
121
+ raise click.UsageError(
122
+ "Product target must resolve to a full URI like 's3://bucket/path' or "
123
+ f"'file:///abs/path'. Bare paths are no longer supported. Got: {raw!r}"
124
+ )
125
+ return StorageUri.parse(raw)
126
+
127
+
128
+ def _base_uri_from_storage_config(storage_config: StorageConfig) -> StorageUri | None:
129
+ identity = identity_from_storage_config(storage_config)
130
+ return identity.product_uri if identity is not None else None
131
+
132
+
133
+ @overload
134
+ def resolve_cli_product(product: str, *, format: str = "zarr") -> tuple[str, StorageUri]: ...
135
+
136
+
137
+ @overload
138
+ def resolve_cli_product(product: None, *, format: str = "zarr") -> tuple[None, None]: ...
139
+
140
+
141
+ def resolve_cli_product(
142
+ product: str | None, *, format: str = "zarr"
143
+ ) -> tuple[str | None, StorageUri | None]:
144
+ if product is None:
145
+ return None, None
146
+ if "://" not in product:
147
+ return product, None
148
+ product_label = product.rstrip("/").rsplit("/", 1)[-1]
149
+ identity = resolve_product_identity(
150
+ product, format=format, product_name=product_label, option_name="--product-name"
151
+ )
152
+ return identity.product_name, identity.product_uri
153
+
154
+
155
+ def storage_config_from_ctx(ctx: click.Context) -> StorageConfig:
156
+ storage_config_obj: StorageConfig | None = ctx.obj.get("storage_config")
157
+ if storage_config_obj is None:
158
+ raise click.ClickException(
159
+ "Storage configuration not available. Define [storage] in config.toml or use --manifest-only."
160
+ )
161
+ return storage_config_obj
@@ -0,0 +1,294 @@
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
+ import sys
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from firecube.cli._product import require_full_uri
24
+ from firecube.cli._shared_options import dry_run_flag, yes_flag
25
+ from firecube.core.controlplane.types import AbandonSweepResult
26
+ from firecube.core.errors import ManifestError
27
+ from firecube.core.storage.uri import StorageUri
28
+
29
+ from ._manager import resolve_cli_product, resolve_manager
30
+
31
+
32
+ @click.group("runs")
33
+ def runs_group() -> None:
34
+ """manage ingestion run records
35
+
36
+ inspect and manage tracked chunk runs."""
37
+
38
+
39
+ def _echo_abandon_run_result(*, product_name: str, run_id: str, result: dict[str, object]) -> None:
40
+ if not result.get("abandoned"):
41
+ click.echo(f"Run {run_id} already terminal with status={result.get('status')}")
42
+ return
43
+ click.echo(f"Abandoned run {run_id} for {product_name}")
44
+
45
+
46
+ def _echo_abandon_sweep_result(
47
+ *,
48
+ product_name: str,
49
+ result: AbandonSweepResult,
50
+ dry_run: bool,
51
+ ) -> None:
52
+ if not result.previewed:
53
+ click.echo("No stale runs found.")
54
+ return
55
+
56
+ if dry_run:
57
+ click.echo(f"[dry-run] Would abandon stale runs for {product_name}:")
58
+ for run_id in result.previewed:
59
+ click.echo(f" - {run_id}")
60
+ return
61
+
62
+ click.echo(f"Abandoned stale runs for {product_name}:")
63
+ if result.abandoned:
64
+ click.echo(" Abandoned:")
65
+ for run_id in result.abandoned:
66
+ click.echo(f" - {run_id}")
67
+ if result.skipped_fresh:
68
+ click.echo(" Skipped fresh:")
69
+ for run_id in result.skipped_fresh:
70
+ click.echo(f" - {run_id}")
71
+ if result.skipped_already_terminal:
72
+ click.echo(" Skipped already terminal:")
73
+ for run_id in result.skipped_already_terminal:
74
+ click.echo(f" - {run_id}")
75
+
76
+
77
+ @runs_group.command(
78
+ "list",
79
+ epilog="""\b
80
+ Examples:
81
+ # list all runs for a product
82
+ firecube chunks runs list --product-name <product>
83
+
84
+ \b
85
+ # filter by status
86
+ firecube chunks runs list --product-name <product> --status started
87
+
88
+ \b
89
+ # output as JSON
90
+ firecube chunks runs list --product-name <product> -f json
91
+
92
+ See also: firecube chunks runs abandon, firecube chunks list
93
+ """,
94
+ )
95
+ @click.option("-n", "--product-name", "product_name", required=True, help="product to inspect")
96
+ @click.option(
97
+ "--target",
98
+ "target",
99
+ default=None,
100
+ metavar="URI",
101
+ help=(
102
+ "Explicit product target URI (file://... or s3://...). "
103
+ "Overrides config/env backend resolution. Use when --product-name "
104
+ "alone resolves to the wrong backend (e.g. after moving a product "
105
+ "or when config points elsewhere)."
106
+ ),
107
+ )
108
+ @click.option(
109
+ "--status", default=None, help="filter runs by status (started, complete, failed, abandoned)"
110
+ )
111
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
112
+ @click.option(
113
+ "-f",
114
+ "--format",
115
+ "output_format",
116
+ type=click.Choice(["table", "json"]),
117
+ default="table",
118
+ help="output format",
119
+ )
120
+ @click.pass_context
121
+ def list_cmd(
122
+ ctx: click.Context,
123
+ product_name: str,
124
+ target: str | None,
125
+ status: str | None,
126
+ workspace: Path | None,
127
+ output_format: str,
128
+ ) -> None:
129
+ """list tracked ingestion runs
130
+
131
+ list tracked ingestion runs for a product from the .firecube/ control
132
+ plane. shows run status (started, complete, failed, abandoned), timestamps,
133
+ and span counts. use to identify stuck or failed runs before abandoning"""
134
+ if target is not None:
135
+ require_full_uri(target, option_name="--target")
136
+ try:
137
+ product_uri = StorageUri.parse(target)
138
+ except ValueError as exc:
139
+ raise click.UsageError(str(exc)) from exc
140
+ else:
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
+ runs = manager.list_runs(product=product_name, status=status)
144
+
145
+ if output_format == "json":
146
+ click.echo(
147
+ json.dumps(
148
+ [
149
+ {
150
+ "product": run.product,
151
+ "run_id": run.run_id,
152
+ "status": run.status,
153
+ "started_at": run.started_at,
154
+ "updated_at": run.updated_at,
155
+ "completed_at": run.completed_at,
156
+ "slot_range": (
157
+ list(run.slot_range) if run.slot_range is not None else None
158
+ ),
159
+ "slot_group": run.slot_group,
160
+ "events": run.events,
161
+ "parts": run.parts,
162
+ "stale": run.stale,
163
+ "error": run.error,
164
+ }
165
+ for run in runs
166
+ ],
167
+ indent=2,
168
+ )
169
+ )
170
+ return
171
+
172
+ if not runs:
173
+ click.echo(f"No runs found for {product_name}.")
174
+ return
175
+
176
+ click.echo(f"{'Run ID':<36} {'Status':<10} {'State':<8} {'Parts':<5} {'Events':<6}")
177
+ click.echo("-" * 80)
178
+ for run in runs:
179
+ state = "stale" if run.stale else "active"
180
+ click.echo(f"{run.run_id:<36} {run.status:<10} {state:<8} {run.parts:<5} {run.events:<6}")
181
+
182
+
183
+ @runs_group.command(
184
+ "abandon",
185
+ epilog="""\b
186
+ Examples:
187
+ # preview what would be abandoned
188
+ firecube chunks runs abandon --product-name <product> --run-id <run-id> --reason "crashed" --dry-run
189
+
190
+ \b
191
+ # abandon a stuck run in non-interactive context
192
+ firecube chunks runs abandon --product-name <product> --run-id <run-id> --reason "process crashed" \\
193
+ --yes-i-really-mean-it
194
+
195
+ See also: firecube chunks runs list, firecube chunks list
196
+ """,
197
+ )
198
+ @click.option("-n", "--product-name", "product_name", required=True, help="product owning the run")
199
+ @click.option(
200
+ "--target",
201
+ "target",
202
+ default=None,
203
+ metavar="URI",
204
+ help=(
205
+ "Explicit product target URI (file://... or s3://...). "
206
+ "Overrides config/env backend resolution. Use when --product-name "
207
+ "alone resolves to the wrong backend (e.g. after moving a product "
208
+ "or when config points elsewhere)."
209
+ ),
210
+ )
211
+ @click.option(
212
+ "--run-id",
213
+ default=None,
214
+ help="run identifier (mutually exclusive with --all-stale)",
215
+ )
216
+ @click.option(
217
+ "--all-stale",
218
+ is_flag=True,
219
+ default=False,
220
+ help=("abandon all stale non-terminal runs for the product (mutually exclusive with --run-id)"),
221
+ )
222
+ @click.option("--reason", required=True, help="operator reason for abandonment")
223
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
224
+ @dry_run_flag
225
+ @yes_flag
226
+ @click.pass_context
227
+ def abandon_cmd(
228
+ ctx: click.Context,
229
+ product_name: str,
230
+ target: str | None,
231
+ run_id: str | None,
232
+ all_stale: bool,
233
+ reason: str,
234
+ workspace: Path | None,
235
+ dry_run: bool,
236
+ yes_i_really_mean_it: bool,
237
+ ) -> None:
238
+ """mark a stuck run as abandoned
239
+
240
+ mark a non-terminal ingestion run as abandoned to unblock resume. a run
241
+ stuck in 'started' state blocks future ingestion for that product. use this
242
+ after confirming the original process is no longer active"""
243
+ if target is not None:
244
+ require_full_uri(target, option_name="--target")
245
+ try:
246
+ product_uri = StorageUri.parse(target)
247
+ except ValueError as exc:
248
+ raise click.UsageError(str(exc)) from exc
249
+ else:
250
+ product_name, product_uri = resolve_cli_product(product_name)
251
+
252
+ if (run_id is not None) == all_stale:
253
+ raise click.UsageError("Provide exactly one of --run-id or --all-stale.")
254
+
255
+ if all_stale:
256
+ manager = resolve_manager(
257
+ ctx, workspace, product_uri=product_uri, product_name=product_name
258
+ )
259
+ effective_dry_run = dry_run or not yes_i_really_mean_it
260
+ result = manager.abandon_stale_runs(
261
+ product=product_name,
262
+ reason=reason,
263
+ dry_run=effective_dry_run,
264
+ )
265
+ _echo_abandon_sweep_result(
266
+ product_name=product_name,
267
+ result=result,
268
+ dry_run=effective_dry_run,
269
+ )
270
+ return
271
+
272
+ assert run_id is not None
273
+
274
+ if not dry_run and not yes_i_really_mean_it:
275
+ if not sys.stdin.isatty():
276
+ raise click.UsageError(
277
+ "Confirmation required: re-run with --yes-i-really-mean-it or run interactively."
278
+ )
279
+ click.confirm(f"Abandon run '{run_id}'?", abort=True)
280
+
281
+ if dry_run:
282
+ click.echo(
283
+ f"[dry-run] Would abandon run '{run_id}' for product "
284
+ f"'{product_name}' (reason: {reason})"
285
+ )
286
+ return
287
+
288
+ manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
289
+ try:
290
+ result = manager.abandon_run(product=product_name, run_id=run_id, reason=reason)
291
+ except ManifestError as exc:
292
+ raise click.ClickException(str(exc)) from exc
293
+
294
+ _echo_abandon_run_result(product_name=product_name, run_id=run_id, result=result)
@@ -0,0 +1,165 @@
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 firecube.cli._shared_options import dry_run_flag
23
+
24
+ from ._manager import resolve_cli_product, resolve_manager
25
+
26
+
27
+ @click.group("snapshots")
28
+ def snapshots_group() -> None:
29
+ """manage control-plane snapshots
30
+
31
+ rebuild derived chunk snapshots."""
32
+
33
+
34
+ @snapshots_group.command(
35
+ "rebuild",
36
+ epilog="""\b
37
+ Examples:
38
+ # preview a snapshot rebuild
39
+ firecube chunks snapshots rebuild --product-name <product> --dry-run
40
+
41
+ \b
42
+ # rebuild snapshot for a product
43
+ firecube chunks snapshots rebuild --product-name <product>
44
+
45
+ \b
46
+ # rebuild and output result as JSON
47
+ firecube chunks snapshots rebuild --product-name <product> -f json
48
+
49
+ See also: firecube chunks snapshots status, firecube chunks list
50
+ """,
51
+ )
52
+ @click.option(
53
+ "-n",
54
+ "--product-name",
55
+ "product_name",
56
+ required=True,
57
+ help="product to rebuild into a fresh chunk snapshot",
58
+ )
59
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
60
+ @click.option(
61
+ "-f",
62
+ "--format",
63
+ "output_format",
64
+ type=click.Choice(["table", "json"]),
65
+ default="table",
66
+ help="output format",
67
+ )
68
+ @dry_run_flag
69
+ @click.pass_context
70
+ def rebuild_cmd(
71
+ ctx: click.Context,
72
+ product_name: str,
73
+ workspace: Path | None,
74
+ output_format: str,
75
+ dry_run: bool,
76
+ ) -> None:
77
+ """rebuild the chunk snapshot
78
+
79
+ rebuild the derived chunk snapshot for a product from the chunk event log.
80
+ snapshots are cached read models; use after the snapshot is missing,
81
+ corrupt, or very stale to restore fast query performance for chunks list"""
82
+ product_name, product_uri = resolve_cli_product(product_name)
83
+
84
+ if dry_run:
85
+ click.echo(f"[dry-run] Would rebuild snapshot for product '{product_name}'")
86
+ return
87
+
88
+ manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
89
+ result = manager.rebuild_snapshot(product_name)
90
+
91
+ if output_format == "json":
92
+ click.echo(json.dumps(result, indent=2))
93
+ return
94
+
95
+ if result.get("locked"):
96
+ click.echo(
97
+ f"Snapshot rebuild skipped for {product_name}: another rebuild is already in progress."
98
+ )
99
+ return
100
+
101
+ click.echo(
102
+ f"Rebuilt snapshot for {product_name}: generation={result.get('generation')} "
103
+ f"records={result.get('records', 0)}"
104
+ )
105
+
106
+
107
+ @snapshots_group.command(
108
+ "status",
109
+ epilog="""\b
110
+ Examples:
111
+ # check snapshot status for a product
112
+ firecube chunks snapshots status --product-name <product>
113
+
114
+ \b
115
+ # output as JSON
116
+ firecube chunks snapshots status --product-name <product> -f json
117
+
118
+ See also: firecube chunks snapshots rebuild, firecube chunks list
119
+ """,
120
+ )
121
+ @click.option(
122
+ "-n",
123
+ "--product-name",
124
+ "product_name",
125
+ required=True,
126
+ help="product to check snapshot status for",
127
+ )
128
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
129
+ @click.option(
130
+ "-f",
131
+ "--format",
132
+ "output_format",
133
+ type=click.Choice(["table", "json"]),
134
+ default="table",
135
+ help="output format",
136
+ )
137
+ @click.pass_context
138
+ def status_cmd(
139
+ ctx: click.Context,
140
+ product_name: str,
141
+ workspace: Path | None,
142
+ output_format: str,
143
+ ) -> None:
144
+ """show snapshot age and status
145
+
146
+ show snapshot age and status for a product's .firecube/ control plane.
147
+ reports whether the snapshot exists, how many events it covers, and when
148
+ it was last rebuilt. use to decide whether a rebuild is needed"""
149
+ product_name, product_uri = resolve_cli_product(product_name)
150
+ manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
151
+ result = manager.snapshot_status(product_name)
152
+
153
+ if output_format == "json":
154
+ click.echo(json.dumps(result, indent=2))
155
+ return
156
+
157
+ if not result.get("exists"):
158
+ click.echo(f"No snapshot found for {product_name}")
159
+ return
160
+
161
+ click.echo(f"Product: {product_name}")
162
+ click.echo(f"Age: {result['age_human']}")
163
+ click.echo(f"Cutoff: {result.get('completed_before', 'unknown')}")
164
+ click.echo(f"Generation: {result.get('generation', 'unknown')}")
165
+ click.echo(f"Records: {result.get('records', 'unknown')}")
@@ -0,0 +1,73 @@
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
+
19
+ import click
20
+ from click.shell_completion import get_completion_class
21
+
22
+
23
+ @click.command(
24
+ "completion",
25
+ context_settings={"help_option_names": ["-h", "--help"]},
26
+ epilog="""\b
27
+ Examples:
28
+ # enable completion for bash (add to ~/.bashrc)
29
+ eval "$(firecube completion bash)"
30
+
31
+ 
32
+ # enable completion for zsh (add to ~/.zshrc)
33
+ eval "$(firecube completion zsh)"
34
+
35
+ 
36
+ # enable completion for fish
37
+ firecube completion fish | source
38
+ """,
39
+ )
40
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False))
41
+ @click.option(
42
+ "--output",
43
+ "output_path",
44
+ type=click.Path(path_type=Path),
45
+ help="write the completion script to a file instead of stdout",
46
+ )
47
+ @click.pass_context
48
+ def completion_cmd(ctx: click.Context, shell: str, output_path: Path | None) -> None:
49
+ """generate shell completion scripts
50
+
51
+ Generates a shell completion script for firecube for bash, zsh, or fish.
52
+ Run the output through eval in your shell profile to enable tab
53
+ completion for all firecube commands and options.
54
+ """
55
+ root = ctx.find_root()
56
+ # Always generate scripts for the installed console command name.
57
+ prog_name = "firecube"
58
+ complete_var = "_FIRECUBE_COMPLETE"
59
+
60
+ complete_cls = get_completion_class(shell)
61
+ if complete_cls is None:
62
+ raise click.ClickException(f"Unsupported shell: {shell}")
63
+
64
+ script = complete_cls(root.command, {}, prog_name, complete_var).source()
65
+ if output_path is not None:
66
+ output_path.parent.mkdir(parents=True, exist_ok=True)
67
+ output_path.write_text(script)
68
+ click.echo(f"Wrote {shell} completion script to {output_path}")
69
+ return
70
+ click.echo(script)
71
+
72
+
73
+ __all__ = ["completion_cmd"]