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,196 @@
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
+ """CLI helpers for catalog-related operations (e.g. Intake)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ import click
24
+
25
+ from firecube.cli._ctx import get_storage_config
26
+ from firecube.cli._product import resolve_product_identity
27
+ from firecube.cli._shared_options import (
28
+ product_uri_option,
29
+ storage_driver_option,
30
+ storage_type_option,
31
+ )
32
+ from firecube.cli._uri_policy import apply_smart_default, parse_product_uri
33
+ from firecube.core import observability
34
+ from firecube.core.intake import (
35
+ build_catalog_source_specs,
36
+ build_intake_catalog,
37
+ discover_catalog_groups,
38
+ )
39
+ from firecube.core.storage.binding import StorageBinding
40
+ from firecube.core.storage.driver_config import StorageDriverConfig
41
+ from firecube.core.storage.session import StorageSession
42
+ from firecube.ingestor.registry.loader import discover_ingestors
43
+
44
+ log = logging.getLogger("firecube.cli")
45
+
46
+
47
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
48
+ def catalog() -> None:
49
+ """generate Intake YAML catalogs
50
+
51
+ Generate Intake YAML catalogs for Firecube products. Use catalog intake to
52
+ produce a machine-readable catalog that describes a product's storage
53
+ layout, groups, and storage options for use with the Intake data discovery
54
+ library.
55
+ """
56
+
57
+
58
+ @catalog.command(
59
+ epilog="""\b
60
+ Examples:
61
+ # generate an Intake catalog for a product
62
+ firecube catalog intake <plugin> -p <product> -o <path>/catalog.yaml --collection-id <id>
63
+
64
+ \b
65
+ # generate without storage_options (for local or public stores)
66
+ firecube catalog intake <plugin> -p <product> -o <path>/catalog.yaml --collection-id <id> --no-storage-options
67
+
68
+ See also: firecube plugins list, firecube zarr validate
69
+ """,
70
+ )
71
+ @click.argument("plugin")
72
+ @product_uri_option(tier="inspect")
73
+ @click.option(
74
+ "-o",
75
+ "--output",
76
+ type=str,
77
+ required=True,
78
+ help="Output artifact URI (file:///abs/path); s3:// accepted but runtime support coming later",
79
+ )
80
+ @click.option(
81
+ "--collection-id",
82
+ required=True,
83
+ help="explicit collection id for the generated catalog",
84
+ )
85
+ @click.option(
86
+ "--include-storage-options/--no-storage-options",
87
+ default=True,
88
+ show_default=True,
89
+ help="include generic storage_options using FIRECUBE_* placeholders",
90
+ )
91
+ @storage_driver_option(required=False)
92
+ @storage_type_option(required=False)
93
+ @click.pass_context
94
+ def intake(
95
+ ctx: click.Context,
96
+ plugin: str,
97
+ product: str,
98
+ output: str,
99
+ collection_id: str,
100
+ include_storage_options: bool,
101
+ storage_driver: str | None,
102
+ storage_type: str | None,
103
+ ) -> None:
104
+ """generate an Intake product catalog
105
+
106
+ Generates an Intake YAML catalog for a supported product store. Discovers
107
+ dataset groups using the specified plugin and writes a catalog file with
108
+ source specs and optional storage_options placeholders. Requires
109
+ --product, --output, and --collection-id.
110
+ """
111
+ observability.init_observability("firecube-catalog-intake")
112
+
113
+ # Ensure plugins are discovered before we try to instantiate the ingestor.
114
+ plugins = discover_ingestors()
115
+ if plugin not in plugins:
116
+ raise click.ClickException(f"Unknown plugin: {plugin}")
117
+
118
+ ingestor_cls = plugins[plugin]
119
+
120
+ parsed_uri = parse_product_uri(product)
121
+ storage_type = apply_smart_default(parsed_uri, storage_type)
122
+ parsed_output = parse_product_uri(output)
123
+ if parsed_output.scheme == "s3":
124
+ raise click.ClickException("Remote artifact output not yet supported")
125
+ local_output = Path(parsed_output.normalized.removeprefix("file://"))
126
+ storage_config = get_storage_config(
127
+ ctx,
128
+ overrides={"storage_type": storage_type, "storage_driver": storage_driver},
129
+ cache=False,
130
+ )
131
+
132
+ driver_config = StorageDriverConfig.from_storage_config(storage_config)
133
+ identity = resolve_product_identity(
134
+ parsed_uri.normalized,
135
+ format="zarr",
136
+ product_name=parsed_uri.normalized,
137
+ )
138
+ session = StorageSession(
139
+ StorageBinding(
140
+ identity=identity,
141
+ driver=driver_config,
142
+ )
143
+ )
144
+
145
+ store_uri = identity.product_uri.to_str()
146
+
147
+ # Instantiate ingestor and let Firecube discover/annotate catalog groups.
148
+ ingestor = ingestor_cls()
149
+ try:
150
+ groups = discover_catalog_groups(store_uri, storage_session=session)
151
+ except Exception as exc:
152
+ raise click.ClickException(str(exc)) from exc
153
+ source_specs = build_catalog_source_specs(
154
+ plugin_name=plugin,
155
+ product=parsed_uri.normalized,
156
+ store_uri=store_uri,
157
+ groups=groups,
158
+ group_info_resolver=getattr(ingestor, "catalog_group_info", None),
159
+ storage_session=session,
160
+ )
161
+ if not source_specs:
162
+ raise click.ClickException(
163
+ f"No catalogable dataset groups found for plugin '{plugin}' under product '{parsed_uri.normalized}'."
164
+ )
165
+
166
+ catalog_name = plugin
167
+ catalog_description = f"Firecube product '{parsed_uri.normalized}' for plugin '{plugin}'."
168
+ catalog_dict = build_intake_catalog(
169
+ catalog_name=catalog_name,
170
+ catalog_description=catalog_description,
171
+ collection_id=collection_id,
172
+ store_uri=store_uri,
173
+ sources=source_specs,
174
+ include_storage_options=include_storage_options,
175
+ )
176
+
177
+ # Dump YAML safely; fall back to JSON if PyYAML is unavailable.
178
+ try:
179
+ import yaml # type: ignore[import-not-found]
180
+
181
+ local_output.parent.mkdir(parents=True, exist_ok=True)
182
+ with local_output.open("w", encoding="utf-8") as fh:
183
+ yaml.safe_dump(catalog_dict, fh, sort_keys=False)
184
+ except Exception as exc:
185
+ # As a fallback, write JSON so the user still gets a usable artifact.
186
+ log.warning("Failed to write YAML catalog (%s); falling back to JSON.", exc)
187
+ output_json = local_output.with_suffix(".json")
188
+ output_json.parent.mkdir(parents=True, exist_ok=True)
189
+ with output_json.open("w", encoding="utf-8") as fh:
190
+ json.dump(catalog_dict, fh, indent=2)
191
+ click.echo(
192
+ f"Intake catalog written as JSON fallback to: {output_json}",
193
+ )
194
+ return
195
+
196
+ click.echo(f"Intake catalog written to: {local_output}")
@@ -0,0 +1,74 @@
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
+ """Chunk management commands (firecube chunks)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from firecube.core import observability
24
+
25
+ from ._claims import claims_group
26
+ from ._delete import delete_cmd, delete_span_cmd
27
+ from ._list import list_cmd
28
+ from ._runs import runs_group
29
+ from ._snapshots import snapshots_group
30
+
31
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
32
+
33
+
34
+ @click.group(context_settings=CONTEXT_SETTINGS)
35
+ @click.option(
36
+ "--workspace", type=click.Path(path_type=Path), help="local workspace directory to use"
37
+ )
38
+ @click.option(
39
+ "--quiet",
40
+ "quiet",
41
+ is_flag=True,
42
+ default=False,
43
+ help="Suppress storage configuration banner.",
44
+ )
45
+ @click.pass_context
46
+ def chunks(
47
+ ctx: click.Context,
48
+ workspace: Path | None,
49
+ quiet: bool,
50
+ ) -> None:
51
+ """inspect and manage chunk records
52
+
53
+ Inspect and manage tracked chunk records across all products.
54
+
55
+ \b
56
+ firecube tracks chunk state in a product-local control plane
57
+ (event log, snapshots, claims). use chunks list to inspect, chunks delete
58
+ to remove storage and records, and chunks runs to review ingestion run
59
+ history"""
60
+ observability.init_observability("firecube-chunks")
61
+ ctx.ensure_object(dict)
62
+ if workspace is not None:
63
+ ctx.obj["workspace"] = workspace
64
+ ctx.obj["chunks_quiet"] = quiet
65
+
66
+
67
+ chunks.add_command(list_cmd)
68
+ chunks.add_command(delete_cmd)
69
+ chunks.add_command(delete_span_cmd)
70
+ chunks.add_command(claims_group)
71
+ chunks.add_command(runs_group)
72
+ chunks.add_command(snapshots_group)
73
+
74
+ __all__ = ["chunks"]
@@ -0,0 +1,229 @@
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._shared_options import dry_run_flag, yes_flag
24
+ from firecube.core.controlplane.types import ClearSweepResult
25
+ from firecube.core.errors import ClaimConflictError
26
+
27
+ from ._manager import resolve_cli_product, resolve_manager
28
+
29
+
30
+ @click.group("claims")
31
+ def claims_group() -> None:
32
+ """manage write-coordination claims
33
+
34
+ inspect and manage chunk write claims."""
35
+
36
+
37
+ @claims_group.command(
38
+ "list",
39
+ epilog="""\b
40
+ Examples:
41
+ # list all claims across all products
42
+ firecube chunks claims list
43
+
44
+ \b
45
+ # list claims for a specific product
46
+ firecube chunks claims list --product-name <product>
47
+
48
+ See also: firecube chunks claims clear, firecube chunks runs list
49
+ """,
50
+ )
51
+ @click.option("-n", "--product-name", "product_name", help="filter by product name")
52
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
53
+ @click.option(
54
+ "-f",
55
+ "--format",
56
+ "output_format",
57
+ type=click.Choice(["table", "json"]),
58
+ default="table",
59
+ help="output format",
60
+ )
61
+ @click.pass_context
62
+ def list_cmd(
63
+ ctx: click.Context,
64
+ product_name: str | None,
65
+ workspace: Path | None,
66
+ output_format: str,
67
+ ) -> None:
68
+ """list active write claims
69
+
70
+ list active write-coordination claims for one or all products. claims are
71
+ short-lived locks written to .firecube/claims/ to coordinate concurrent
72
+ writes. stale claims from crashed processes can block ingestion -- use
73
+ claims clear to release them"""
74
+ product_name, product_uri = resolve_cli_product(product_name)
75
+ manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
76
+ claims = manager.list_claims(product=product_name)
77
+
78
+ if output_format == "json":
79
+ click.echo(
80
+ json.dumps(
81
+ [
82
+ {
83
+ "product": claim.product,
84
+ "domain": claim.domain,
85
+ "owner_id": claim.owner_id,
86
+ "claim_path": claim.claim_path,
87
+ "acquired_at": claim.acquired_at,
88
+ "last_heartbeat_at": claim.last_heartbeat_at,
89
+ "heartbeat_interval_s": claim.heartbeat_interval_s,
90
+ "stale_threshold_s": claim.stale_threshold_s,
91
+ "stale": claim.stale,
92
+ }
93
+ for claim in claims
94
+ ],
95
+ indent=2,
96
+ )
97
+ )
98
+ return
99
+
100
+ if not claims:
101
+ click.echo("No claims found.")
102
+ return
103
+
104
+ click.echo(f"{'Product':<24} {'State':<8} {'Owner':<24} Domain")
105
+ click.echo("-" * 92)
106
+ for claim in claims:
107
+ state = "stale" if claim.stale else "active"
108
+ click.echo(f"{claim.product:<24} {state:<8} {claim.owner_id:<24} {claim.domain}")
109
+
110
+
111
+ @claims_group.command(
112
+ "clear",
113
+ epilog="""\b
114
+ Examples:
115
+ # clear a stale claim (interactive confirmation)
116
+ firecube chunks claims clear --product-name <product> --domain <domain>
117
+
118
+ \b
119
+ # clear a stale claim in non-interactive context
120
+ firecube chunks claims clear --product-name <product> --domain <domain> --yes-i-really-mean-it
121
+
122
+ \b
123
+ # bypass the is_stale check (--force) and confirm (--yes-i-really-mean-it)
124
+ firecube chunks claims clear --product-name <product> --domain <domain> --force --yes-i-really-mean-it
125
+
126
+ See also: firecube chunks claims list, firecube chunks runs abandon
127
+ """,
128
+ )
129
+ @click.option(
130
+ "-n", "--product-name", "product_name", required=True, help="product owning the claim"
131
+ )
132
+ @click.option("--domain", "domain_id", help="full write-domain identifier")
133
+ @click.option(
134
+ "--all-stale",
135
+ "all_stale",
136
+ is_flag=True,
137
+ default=False,
138
+ help="clear all stale claims for the product (mutually exclusive with --domain)",
139
+ )
140
+ @click.option("--workspace", type=click.Path(path_type=Path), help="workspace directory override")
141
+ @click.option(
142
+ "--force",
143
+ is_flag=True,
144
+ help="operational bypass: clear even if the claim is still active (skips is_stale check)",
145
+ )
146
+ @dry_run_flag
147
+ @yes_flag
148
+ @click.pass_context
149
+ def clear_cmd(
150
+ ctx: click.Context,
151
+ product_name: str,
152
+ domain_id: str | None,
153
+ all_stale: bool,
154
+ workspace: Path | None,
155
+ force: bool,
156
+ dry_run: bool,
157
+ yes_i_really_mean_it: bool,
158
+ ) -> None:
159
+ """clear a blocking write claim
160
+
161
+ clear a blocking write-coordination claim for a product and domain.
162
+ claims are normally released when a writer exits cleanly; use this when a
163
+ crashed process left a stale claim that blocks ingestion. verify the
164
+ original writer is no longer running first.
165
+
166
+ --force bypasses the is_stale check (operational bypass for active claims).
167
+ --yes-i-really-mean-it is the confirmation flag, required in non-TTY contexts."""
168
+ if bool(domain_id) == all_stale:
169
+ raise click.UsageError("Provide exactly one of --domain or --all-stale.")
170
+
171
+ if dry_run and not all_stale:
172
+ raise click.UsageError("--dry-run is only supported with --all-stale.")
173
+
174
+ product_name, product_uri = resolve_cli_product(product_name)
175
+ manager = resolve_manager(ctx, workspace, product_uri=product_uri, product_name=product_name)
176
+
177
+ if all_stale:
178
+ dry_run = dry_run or not yes_i_really_mean_it
179
+ result = manager.clear_stale_claims(product=product_name, dry_run=dry_run)
180
+ _echo_clear_sweep_result(result, dry_run=dry_run)
181
+ return
182
+
183
+ if not yes_i_really_mean_it:
184
+ if not sys.stdin.isatty():
185
+ raise click.UsageError(
186
+ "Confirmation required: re-run with --yes-i-really-mean-it or run interactively."
187
+ )
188
+ click.confirm("Clear claims?", abort=True)
189
+
190
+ if domain_id is None:
191
+ raise click.UsageError("Provide --domain when not using --all-stale.")
192
+ try:
193
+ cleared = manager.clear_claim(product=product_name, domain_id=domain_id, force=force)
194
+ except ClaimConflictError as exc:
195
+ raise click.ClickException(str(exc)) from exc
196
+
197
+ if not cleared:
198
+ raise click.ClickException(f"No claim found for product={product_name} domain={domain_id}")
199
+
200
+ click.echo(f"Cleared claim for {domain_id}")
201
+
202
+
203
+ def _echo_clear_sweep_result(result: ClearSweepResult, *, dry_run: bool) -> None:
204
+ if not result.previewed:
205
+ click.echo("No stale claims found")
206
+ return
207
+
208
+ click.echo("Previewed stale claims:")
209
+ for domain_id in result.previewed:
210
+ click.echo(f" - {domain_id}")
211
+
212
+ if dry_run:
213
+ click.echo("Dry run only; no claims cleared.")
214
+ else:
215
+ click.echo("Cleared stale claims:")
216
+ if result.cleared:
217
+ for domain_id in result.cleared:
218
+ click.echo(f" - {domain_id}")
219
+ else:
220
+ click.echo(" - none")
221
+
222
+ click.echo(f"Skipped fresh claims: {len(result.skipped_fresh)}")
223
+ if result.skipped_fresh:
224
+ for domain_id in result.skipped_fresh:
225
+ click.echo(f" - {domain_id}")
226
+ click.echo(f"Skipped missing claims: {len(result.skipped_missing)}")
227
+ if result.skipped_missing:
228
+ for domain_id in result.skipped_missing:
229
+ click.echo(f" - {domain_id}")
@@ -0,0 +1,84 @@
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 re
19
+ from datetime import datetime
20
+
21
+ import click
22
+
23
+ _TIME_RANGE_SEPARATOR_RE = re.compile(r":(?=\d{4}-\d{2}-\d{2}(?:$|[T\s]))")
24
+
25
+
26
+ def confirm_deletion(plan, *, manifest_only: bool = False, storage_only: bool = False) -> bool:
27
+ """Confirm a deletion operation (CLI-only)."""
28
+ click.echo("")
29
+ click.echo("DANGEROUS OPERATION")
30
+ click.echo(f"About to delete {plan.count:,} tracked chunk records")
31
+ if plan.total_size > 0:
32
+ if plan.size_gb >= 1:
33
+ click.echo(f"Total size: {plan.size_gb:.1f} GB")
34
+ else:
35
+ click.echo(f"Total size: {plan.size_mb:.1f} MB")
36
+ if plan.products_affected:
37
+ click.echo(f"Products affected: {', '.join(sorted(plan.products_affected))}")
38
+
39
+ if manifest_only:
40
+ click.echo("Mode: tracked-records-only (storage objects will be kept)")
41
+ elif storage_only:
42
+ click.echo("Mode: storage-only (tracked chunk records will be kept)")
43
+ else:
44
+ click.echo("Mode: delete from storage + tracked chunk records")
45
+
46
+ return bool(click.confirm("Continue?", default=False))
47
+
48
+
49
+ def parse_meta_filters(values: tuple[str, ...]) -> dict:
50
+ """Parse repeated --meta key=value filters (value may be JSON)."""
51
+ meta: dict = {}
52
+ for item in values:
53
+ if "=" not in item:
54
+ raise click.BadParameter(f"Invalid --meta '{item}', expected key=value")
55
+ key, raw = item.split("=", 1)
56
+ key = key.strip()
57
+ raw = raw.strip()
58
+ if not key:
59
+ raise click.BadParameter(f"Invalid --meta '{item}', empty key")
60
+ try:
61
+ meta[key] = json.loads(raw)
62
+ except Exception:
63
+ meta[key] = raw
64
+ return meta
65
+
66
+
67
+ def parse_datetime(value: str | None) -> datetime | None:
68
+ """Parse a YYYY-MM-DD string into a datetime."""
69
+ if not value:
70
+ return None
71
+ try:
72
+ return datetime.strptime(value, "%Y-%m-%d")
73
+ except ValueError as exc:
74
+ raise click.BadParameter(f"Invalid date format: {value}. Use YYYY-MM-DD") from exc
75
+
76
+
77
+ def parse_time_range(value: str | None) -> tuple[str, str] | None:
78
+ """Parse 'START:END' into (start_iso, end_iso). Returns None if value is None/empty."""
79
+ if not value:
80
+ return None
81
+ parts = _TIME_RANGE_SEPARATOR_RE.split(value, maxsplit=1)
82
+ if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
83
+ raise click.BadParameter(f"Expected START:END format, got: {value!r}")
84
+ return parts[0].strip(), parts[1].strip()