firecube 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,125 @@
|
|
|
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 dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
19
|
+
|
|
20
|
+
from firecube.core.credentials import Credentials
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from firecube.core.config import StorageConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class StorageDriverConfig:
|
|
28
|
+
"""Credentials are immutable and set once at the run boundary via `from_storage_config()`.
|
|
29
|
+
No mid-run credential rotation is supported."""
|
|
30
|
+
|
|
31
|
+
driver: Literal["fsspec", "obstore"] = "fsspec"
|
|
32
|
+
endpoint_url: str | None = None
|
|
33
|
+
credentials: Credentials | None = None
|
|
34
|
+
region: str | None = None
|
|
35
|
+
path_style: bool = True
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
_check_factory_origin()
|
|
39
|
+
self.validate()
|
|
40
|
+
|
|
41
|
+
def validate(self) -> None:
|
|
42
|
+
if self.driver not in {"fsspec", "obstore"}:
|
|
43
|
+
raise ValueError("driver must be one of {'fsspec', 'obstore'}")
|
|
44
|
+
|
|
45
|
+
if self.endpoint_url is not None and (
|
|
46
|
+
not self.endpoint_url or "://" not in self.endpoint_url
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("endpoint_url must be a non-empty URL-like string")
|
|
49
|
+
|
|
50
|
+
creds = self.credentials
|
|
51
|
+
if creds is not None and (creds.access_key is None) != (creds.secret_key is None):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"credentials.access_key and credentials.secret_key must be provided together"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_storage_config(cls, sc: StorageConfig) -> StorageDriverConfig:
|
|
58
|
+
"""Boundary-only: convert parsed TOML StorageConfig to runtime driver config."""
|
|
59
|
+
driver = cast(Literal["fsspec", "obstore"], sc.storage_driver or "fsspec")
|
|
60
|
+
|
|
61
|
+
creds = None
|
|
62
|
+
if sc.access_key is not None or sc.secret_key is not None:
|
|
63
|
+
creds = Credentials(
|
|
64
|
+
access_key=sc.access_key,
|
|
65
|
+
secret_key=sc.secret_key,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return cls(
|
|
69
|
+
driver=driver,
|
|
70
|
+
endpoint_url=sc.endpoint_url,
|
|
71
|
+
credentials=creds,
|
|
72
|
+
region=sc.region,
|
|
73
|
+
path_style=sc.path_style,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_storage_config_or_default(
|
|
78
|
+
cls,
|
|
79
|
+
config: StorageConfig | None,
|
|
80
|
+
) -> StorageDriverConfig:
|
|
81
|
+
"""Build StorageDriverConfig from optional StorageConfig."""
|
|
82
|
+
if config is None:
|
|
83
|
+
return cls(driver="fsspec")
|
|
84
|
+
return cls.from_storage_config(config)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_factory_origin() -> None:
|
|
88
|
+
"""Boundary enforcement: construction must go through approved factories."""
|
|
89
|
+
import inspect
|
|
90
|
+
import os
|
|
91
|
+
|
|
92
|
+
frame = inspect.currentframe()
|
|
93
|
+
if frame is None:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
caller = frame.f_back
|
|
97
|
+
while caller is not None:
|
|
98
|
+
caller_qualname = caller.f_code.co_qualname
|
|
99
|
+
caller_file = caller.f_globals.get("__file__", "")
|
|
100
|
+
|
|
101
|
+
if caller_qualname in {
|
|
102
|
+
"_check_factory_origin",
|
|
103
|
+
"StorageDriverConfig.__post_init__",
|
|
104
|
+
"StorageDriverConfig.__init__",
|
|
105
|
+
"__create_fn__.<locals>.__init__",
|
|
106
|
+
}:
|
|
107
|
+
caller = caller.f_back
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
test_override = os.environ.get("_TEST_LOCKDOWN_AS_SRC")
|
|
111
|
+
if not test_override and ("test" in caller_file.lower() or "/tests/" in caller_file):
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
approved = {
|
|
115
|
+
"StorageDriverConfig.from_storage_config",
|
|
116
|
+
"StorageDriverConfig.from_storage_config_or_default",
|
|
117
|
+
}
|
|
118
|
+
if caller_qualname in approved:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"StorageDriverConfig must be constructed via from_storage_config() or "
|
|
123
|
+
"from_storage_config_or_default(). Direct construction is not permitted. "
|
|
124
|
+
f"Caller: {caller_qualname!r} in {caller_file!r}"
|
|
125
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
"""Result types returned by storage write operations."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
__all__ = ["StorageWriteResult"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class StorageWriteResult:
|
|
26
|
+
path: str
|
|
27
|
+
bytes_written: int
|
|
28
|
+
files_written: int
|
|
29
|
+
duration_s: float
|
|
30
|
+
storage_type: str
|
|
@@ -0,0 +1,431 @@
|
|
|
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 concurrent.futures
|
|
18
|
+
import contextvars
|
|
19
|
+
import json
|
|
20
|
+
import shutil
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from importlib import import_module
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING, Any, SupportsIndex, cast
|
|
26
|
+
|
|
27
|
+
from firecube.core.config import StorageConfig
|
|
28
|
+
from firecube.core.duckdb.bridge import (
|
|
29
|
+
_DUCKDB_OBSTORE_REMOTE_MESSAGE,
|
|
30
|
+
)
|
|
31
|
+
from firecube.core.errors import StorageError
|
|
32
|
+
from firecube.core.storage.binding import StorageBinding
|
|
33
|
+
from firecube.core.storage.results import StorageWriteResult
|
|
34
|
+
from firecube.core.storage.uri import StorageUri
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from firecube.core.controlplane.manager import ChunkManager
|
|
38
|
+
from firecube.core.filesystem import StorageFilesystem
|
|
39
|
+
from firecube.core.product.identity import ProductIdentity
|
|
40
|
+
from firecube.core.storage.driver_config import (
|
|
41
|
+
StorageDriverConfig,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"_DUCKDB_OBSTORE_REMOTE_MESSAGE",
|
|
46
|
+
"StorageSession",
|
|
47
|
+
"storage_config_from_binding",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _storage_type_for_uri(uri: StorageUri) -> str:
|
|
52
|
+
if uri.is_remote():
|
|
53
|
+
return "s3"
|
|
54
|
+
return "local"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def storage_config_from_binding(binding: StorageBinding) -> StorageConfig:
|
|
58
|
+
"""Compose a plain ``StorageConfig`` from a ``StorageBinding``.
|
|
59
|
+
|
|
60
|
+
Returns a vanilla ``StorageConfig`` carrying only driver-relevant fields
|
|
61
|
+
(``storage_type``, credentials, ``endpoint_url``, ``region``,
|
|
62
|
+
``path_style``, ``storage_driver``). Location fields (``target_path``,
|
|
63
|
+
``bucket``, ``target_uri``) are intentionally absent — callers that need
|
|
64
|
+
those should read from ``binding.identity`` (``ProductIdentity``)
|
|
65
|
+
directly.
|
|
66
|
+
|
|
67
|
+
This is the explicit boundary helper for lower-level APIs that still
|
|
68
|
+
accept a ``StorageConfig`` (e.g. fsspec/obstore zarr-store factories and
|
|
69
|
+
chunk-state helpers). It replaces the removed ``_StorageConfigView``
|
|
70
|
+
bridge subclass.
|
|
71
|
+
"""
|
|
72
|
+
credentials = binding.driver.credentials
|
|
73
|
+
storage_type = _storage_type_for_uri(binding.identity.product_uri)
|
|
74
|
+
return StorageConfig(
|
|
75
|
+
storage_type=storage_type,
|
|
76
|
+
endpoint_url=binding.driver.endpoint_url,
|
|
77
|
+
access_key=credentials.access_key if credentials is not None else None,
|
|
78
|
+
secret_key=credentials.secret_key if credentials is not None else None,
|
|
79
|
+
region=binding.driver.region,
|
|
80
|
+
path_style=binding.driver.path_style,
|
|
81
|
+
storage_driver=binding.driver.driver,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _storage_uri_from_path(*, protocol: str, path: str) -> StorageUri:
|
|
86
|
+
raw_path = str(path)
|
|
87
|
+
if "://" in raw_path:
|
|
88
|
+
return StorageUri.parse(raw_path)
|
|
89
|
+
if protocol == "file":
|
|
90
|
+
return StorageUri.parse(raw_path)
|
|
91
|
+
authority, _, object_path = raw_path.lstrip("/").partition("/")
|
|
92
|
+
return StorageUri(protocol=protocol, authority=authority, path=object_path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_filesystem(binding: StorageBinding) -> Any:
|
|
96
|
+
from firecube.core.filesystem import create_filesystem as _create_filesystem
|
|
97
|
+
|
|
98
|
+
return _create_filesystem(binding)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StorageSession:
|
|
102
|
+
def __init__(self, binding: StorageBinding) -> None:
|
|
103
|
+
"""Pure constructor. No I/O. Preflight is engine-level (T15)."""
|
|
104
|
+
self._binding = binding
|
|
105
|
+
self._filesystem: StorageFilesystem | None = None
|
|
106
|
+
|
|
107
|
+
def __getstate__(self) -> object:
|
|
108
|
+
raise TypeError("StorageSession is process-local and cannot be pickled.")
|
|
109
|
+
|
|
110
|
+
def __reduce__(self) -> str | tuple[Any, ...]:
|
|
111
|
+
raise TypeError("StorageSession is process-local and cannot be pickled.")
|
|
112
|
+
|
|
113
|
+
def __reduce_ex__(self, protocol: SupportsIndex) -> str | tuple[Any, ...]:
|
|
114
|
+
raise TypeError("StorageSession is process-local and cannot be pickled.")
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def product(self) -> ProductIdentity:
|
|
118
|
+
return self._binding.identity
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def driver(self) -> StorageDriverConfig:
|
|
122
|
+
return self._binding.driver
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def zarr(self) -> Any:
|
|
126
|
+
return cast(Any, import_module("firecube.core.zarr.io")).ZarrIO(self)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def duckdb(self) -> Any:
|
|
130
|
+
return cast(Any, import_module("firecube.core.duckdb.io")).DuckDBIO(self)
|
|
131
|
+
|
|
132
|
+
def _full_fs(self) -> Any:
|
|
133
|
+
return cast(Any, self.fs())
|
|
134
|
+
|
|
135
|
+
def fs(self) -> StorageFilesystem:
|
|
136
|
+
from firecube.core.filesystem.instrumentation import (
|
|
137
|
+
InstrumentedFilesystem,
|
|
138
|
+
active_filesystem_metrics,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if self._filesystem is None:
|
|
142
|
+
fs = create_filesystem(self._binding)
|
|
143
|
+
# Cache the underlying (unwrapped) adapter so we can re-wrap dynamically.
|
|
144
|
+
# create_filesystem may itself wrap if metrics were active at call time;
|
|
145
|
+
# unwrap here to keep _filesystem as the canonical bare adapter.
|
|
146
|
+
if isinstance(fs, InstrumentedFilesystem):
|
|
147
|
+
fs = cast(Any, fs)._wrapped
|
|
148
|
+
self._filesystem = fs
|
|
149
|
+
|
|
150
|
+
# Apply instrumentation dynamically per-call: metrics state may have changed
|
|
151
|
+
# since the cache was populated (e.g., preflight ran fs() before
|
|
152
|
+
# collect_filesystem_metrics() started).
|
|
153
|
+
if active_filesystem_metrics() is not None:
|
|
154
|
+
return cast(Any, InstrumentedFilesystem(self._filesystem))
|
|
155
|
+
return cast(Any, self._filesystem)
|
|
156
|
+
|
|
157
|
+
def open(self, uri: StorageUri, mode: str) -> Any:
|
|
158
|
+
return cast(Any, self.fs()).open(uri, mode)
|
|
159
|
+
|
|
160
|
+
def exists(self, uri: StorageUri) -> bool:
|
|
161
|
+
return cast(Any, self.fs()).exists(uri)
|
|
162
|
+
|
|
163
|
+
def find(self, uri: StorageUri) -> Iterator[StorageUri]:
|
|
164
|
+
for path in cast(Any, self.fs()).find(uri):
|
|
165
|
+
if isinstance(path, StorageUri):
|
|
166
|
+
yield path
|
|
167
|
+
else:
|
|
168
|
+
yield _storage_uri_from_path(protocol=uri.protocol, path=str(path))
|
|
169
|
+
|
|
170
|
+
def delete(self, uri: StorageUri) -> None:
|
|
171
|
+
cast(Any, self.fs()).rm(uri, recursive=True)
|
|
172
|
+
|
|
173
|
+
def info(self, uri: StorageUri) -> dict[str, Any]:
|
|
174
|
+
return cast(Any, self.fs()).info(uri)
|
|
175
|
+
|
|
176
|
+
def isdir(self, uri: StorageUri) -> bool:
|
|
177
|
+
return cast(Any, self.fs()).isdir(uri)
|
|
178
|
+
|
|
179
|
+
def makedirs(self, uri: StorageUri, *, exist_ok: bool = True) -> None:
|
|
180
|
+
self._full_fs().makedirs(uri, exist_ok=exist_ok)
|
|
181
|
+
|
|
182
|
+
def ls(self, uri: StorageUri) -> Iterator[StorageUri]:
|
|
183
|
+
for entry in self._full_fs().ls(uri):
|
|
184
|
+
if isinstance(entry, StorageUri):
|
|
185
|
+
yield entry
|
|
186
|
+
else:
|
|
187
|
+
yield _storage_uri_from_path(protocol=uri.protocol, path=str(entry))
|
|
188
|
+
|
|
189
|
+
def put(
|
|
190
|
+
self,
|
|
191
|
+
local_path: Path,
|
|
192
|
+
dst: StorageUri,
|
|
193
|
+
*,
|
|
194
|
+
multipart_threshold: int = 64 * 1024 * 1024,
|
|
195
|
+
) -> int:
|
|
196
|
+
from firecube.core.filesystem.protocol import Multipart # pyright: ignore[reportAttributeAccessIssue] # noqa: I001
|
|
197
|
+
|
|
198
|
+
file_size = local_path.stat().st_size
|
|
199
|
+
fs = self.fs()
|
|
200
|
+
if file_size > multipart_threshold and Multipart in cast(Any, fs).capabilities():
|
|
201
|
+
cast(Any, fs).multipart_upload(str(local_path), dst.to_str())
|
|
202
|
+
return file_size
|
|
203
|
+
with local_path.open("rb") as src_handle, cast(Any, fs).open(dst, "wb") as dst_handle:
|
|
204
|
+
shutil.copyfileobj(src_handle, dst_handle)
|
|
205
|
+
return file_size
|
|
206
|
+
|
|
207
|
+
def upload_tree(
|
|
208
|
+
self,
|
|
209
|
+
src: StorageUri,
|
|
210
|
+
dst: StorageUri,
|
|
211
|
+
*,
|
|
212
|
+
preserve_zarr_metadata: bool = True,
|
|
213
|
+
parallel_workers: int = 4,
|
|
214
|
+
multipart_threshold: int = 64 * 1024 * 1024,
|
|
215
|
+
) -> StorageWriteResult:
|
|
216
|
+
"""Upload a local file or directory tree to storage.
|
|
217
|
+
|
|
218
|
+
``parallel_workers`` enables the same thread-pool upload path as the
|
|
219
|
+
legacy S3 writer for directory sources with more than two files. Large
|
|
220
|
+
files route through the filesystem ``Multipart`` capability when it is
|
|
221
|
+
available; obstore accepts ``multipart_threshold`` but handles multipart
|
|
222
|
+
details internally.
|
|
223
|
+
"""
|
|
224
|
+
if src.is_remote():
|
|
225
|
+
raise ValueError("upload_tree requires a local source")
|
|
226
|
+
|
|
227
|
+
from firecube.core.uris import local_path_from_target
|
|
228
|
+
|
|
229
|
+
source_path = local_path_from_target(src.to_str()).resolve()
|
|
230
|
+
if not source_path.exists():
|
|
231
|
+
raise ValueError(f"upload_tree source does not exist: {source_path}")
|
|
232
|
+
if not source_path.is_file() and not source_path.is_dir():
|
|
233
|
+
raise ValueError(f"upload_tree requires a file or directory source: {source_path}")
|
|
234
|
+
|
|
235
|
+
dst_fs = self.fs()
|
|
236
|
+
files = _upload_tree_files(source_path)
|
|
237
|
+
start = time.time()
|
|
238
|
+
bytes_written = 0
|
|
239
|
+
files_written = 0
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
if parallel_workers <= 1 or len(files) <= 2:
|
|
243
|
+
for file_path in files:
|
|
244
|
+
written, uploaded = _upload_tree_upload_file(
|
|
245
|
+
dst_fs,
|
|
246
|
+
dst,
|
|
247
|
+
source_path,
|
|
248
|
+
file_path,
|
|
249
|
+
preserve_zarr_metadata=preserve_zarr_metadata,
|
|
250
|
+
multipart_threshold=multipart_threshold,
|
|
251
|
+
)
|
|
252
|
+
bytes_written += written
|
|
253
|
+
files_written += uploaded
|
|
254
|
+
else:
|
|
255
|
+
actual_workers = min(len(files), parallel_workers)
|
|
256
|
+
first_error: Exception | None = None
|
|
257
|
+
worker_ctx = contextvars.copy_context()
|
|
258
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers)
|
|
259
|
+
futures = {
|
|
260
|
+
executor.submit(
|
|
261
|
+
worker_ctx.copy().run,
|
|
262
|
+
_upload_tree_upload_file_for_target,
|
|
263
|
+
self._binding,
|
|
264
|
+
dst,
|
|
265
|
+
source_path,
|
|
266
|
+
file_path,
|
|
267
|
+
preserve_zarr_metadata,
|
|
268
|
+
multipart_threshold,
|
|
269
|
+
): file_path
|
|
270
|
+
for file_path in files
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
for future in concurrent.futures.as_completed(futures):
|
|
275
|
+
if first_error is not None:
|
|
276
|
+
future.cancel()
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
written, uploaded = future.result()
|
|
280
|
+
bytes_written += written
|
|
281
|
+
files_written += uploaded
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
first_error = exc
|
|
284
|
+
for pending in futures:
|
|
285
|
+
pending.cancel()
|
|
286
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
287
|
+
if first_error is not None:
|
|
288
|
+
raise first_error
|
|
289
|
+
finally:
|
|
290
|
+
if first_error is None:
|
|
291
|
+
executor.shutdown(wait=True)
|
|
292
|
+
except StorageError:
|
|
293
|
+
raise
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
raise StorageError(str(exc) or exc.__class__.__name__) from exc
|
|
296
|
+
|
|
297
|
+
return StorageWriteResult(
|
|
298
|
+
path=dst.to_str(),
|
|
299
|
+
bytes_written=bytes_written,
|
|
300
|
+
files_written=files_written,
|
|
301
|
+
duration_s=time.time() - start,
|
|
302
|
+
storage_type=_storage_type_for_uri(dst),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def control_plane(self) -> ChunkManager:
|
|
306
|
+
from firecube.core.controlplane.manager import ChunkManager
|
|
307
|
+
|
|
308
|
+
manager = cast(Any, ChunkManager)(binding=self._binding, filesystem=self.fs())
|
|
309
|
+
cast(Any, manager).product_name = self.product.product_name
|
|
310
|
+
return manager
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _upload_tree_files(source_path: Path) -> list[Path]:
|
|
314
|
+
if source_path.is_file():
|
|
315
|
+
return [source_path]
|
|
316
|
+
return sorted(
|
|
317
|
+
(path for path in source_path.rglob("*") if path.is_file()), key=lambda p: p.as_posix()
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _upload_tree_destination_uri(
|
|
322
|
+
root: StorageUri, source_path: Path, file_path: Path
|
|
323
|
+
) -> StorageUri:
|
|
324
|
+
if source_path.is_file():
|
|
325
|
+
# File source: dst is the EXACT destination URI/path; do not append source.name.
|
|
326
|
+
# Engine passes full product URI (e.g. s3://bucket/data/product.parquet) as dst,
|
|
327
|
+
# so appending file_path.name would produce s3://bucket/data/product.parquet/product.parquet.
|
|
328
|
+
return root
|
|
329
|
+
rel = file_path.relative_to(source_path)
|
|
330
|
+
return root.join(rel.as_posix())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _upload_tree_upload_file_for_target(
|
|
334
|
+
binding: StorageBinding,
|
|
335
|
+
root: StorageUri,
|
|
336
|
+
source_path: Path,
|
|
337
|
+
file_path: Path,
|
|
338
|
+
preserve_zarr_metadata: bool,
|
|
339
|
+
multipart_threshold: int,
|
|
340
|
+
) -> tuple[int, int]:
|
|
341
|
+
fs = create_filesystem(binding)
|
|
342
|
+
return _upload_tree_upload_file(
|
|
343
|
+
fs,
|
|
344
|
+
root,
|
|
345
|
+
source_path,
|
|
346
|
+
file_path,
|
|
347
|
+
preserve_zarr_metadata=preserve_zarr_metadata,
|
|
348
|
+
multipart_threshold=multipart_threshold,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _upload_tree_upload_file(
|
|
353
|
+
fs: Any,
|
|
354
|
+
root: StorageUri,
|
|
355
|
+
source_path: Path,
|
|
356
|
+
file_path: Path,
|
|
357
|
+
*,
|
|
358
|
+
preserve_zarr_metadata: bool,
|
|
359
|
+
multipart_threshold: int,
|
|
360
|
+
) -> tuple[int, int]:
|
|
361
|
+
dst_uri = _upload_tree_destination_uri(root, source_path, file_path)
|
|
362
|
+
if (
|
|
363
|
+
preserve_zarr_metadata
|
|
364
|
+
and file_path.name == "zarr.json"
|
|
365
|
+
and _should_skip_upload_tree_zarr_json(fs, file_path, dst_uri)
|
|
366
|
+
):
|
|
367
|
+
return 0, 0
|
|
368
|
+
|
|
369
|
+
dst_parent = dst_uri.parent()
|
|
370
|
+
if dst_parent != dst_uri and hasattr(fs, "makedirs"):
|
|
371
|
+
fs.makedirs(dst_parent, exist_ok=True)
|
|
372
|
+
|
|
373
|
+
file_size = file_path.stat().st_size
|
|
374
|
+
if file_size > multipart_threshold:
|
|
375
|
+
if not _upload_tree_multipart(fs, file_path, dst_uri.to_str()):
|
|
376
|
+
raise RuntimeError(f"multipart upload helper returned False for {file_path!s}")
|
|
377
|
+
return file_size, 1
|
|
378
|
+
|
|
379
|
+
with file_path.open("rb") as src_handle, fs.open(dst_uri, "wb") as dst_handle:
|
|
380
|
+
shutil.copyfileobj(src_handle, dst_handle)
|
|
381
|
+
return file_size, 1
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _upload_tree_multipart(fs: Any, local_path: Path, remote_path: str) -> bool:
|
|
385
|
+
from firecube.core.filesystem.fsspec_backend import FsspecFilesystem # noqa: I001
|
|
386
|
+
from firecube.core.filesystem.instrumentation import InstrumentedFilesystem
|
|
387
|
+
from firecube.core.filesystem.obstore_backend import ObstoreFilesystem
|
|
388
|
+
from firecube.core.filesystem.protocol import Multipart # pyright: ignore[reportAttributeAccessIssue]
|
|
389
|
+
|
|
390
|
+
if isinstance(fs, InstrumentedFilesystem):
|
|
391
|
+
fs = fs._wrapped
|
|
392
|
+
|
|
393
|
+
put_file = getattr(fs, "put_file", None)
|
|
394
|
+
if callable(put_file):
|
|
395
|
+
put_file(str(local_path), remote_path)
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
if not isinstance(fs, (FsspecFilesystem, ObstoreFilesystem)):
|
|
399
|
+
return False
|
|
400
|
+
storage_fs = cast(Any, fs)
|
|
401
|
+
if Multipart not in storage_fs.capabilities():
|
|
402
|
+
return False
|
|
403
|
+
storage_fs.multipart_upload(str(local_path), remote_path)
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _upload_tree_shape0(metadata: dict[str, Any]) -> int | None:
|
|
408
|
+
shape = metadata.get("shape", [])
|
|
409
|
+
if not shape:
|
|
410
|
+
return None
|
|
411
|
+
try:
|
|
412
|
+
return int(shape[0])
|
|
413
|
+
except (TypeError, ValueError, IndexError):
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _should_skip_upload_tree_zarr_json(fs: Any, source_path: Path, dest: StorageUri) -> bool:
|
|
418
|
+
if source_path.name != "zarr.json":
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
src_meta = json.loads(source_path.read_text(encoding="utf-8"))
|
|
423
|
+
src_shape = src_meta.get("shape", [])
|
|
424
|
+
if not src_shape:
|
|
425
|
+
return False
|
|
426
|
+
with fs.open(dest, "r") as fh:
|
|
427
|
+
dst_meta = json.load(fh)
|
|
428
|
+
dst_shape = dst_meta.get("shape", [])
|
|
429
|
+
return bool(dst_shape and dst_shape[0] >= src_shape[0])
|
|
430
|
+
except Exception:
|
|
431
|
+
return False
|