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