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,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