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,76 @@
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
+
19
+ from firecube.core.storage.uri import StorageUri
20
+
21
+ VALID_FORMATS = frozenset({"zarr", "parquet", "tensogram"})
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ProductIdentity:
26
+ product_name: str
27
+ product_uri: StorageUri
28
+ control_root_uri: StorageUri
29
+ format: str
30
+
31
+ def __post_init__(self) -> None:
32
+ if self.format not in VALID_FORMATS:
33
+ raise ValueError(
34
+ f"Invalid format {self.format!r}; expected one of {sorted(VALID_FORMATS)}"
35
+ )
36
+
37
+ @classmethod
38
+ def from_uri(cls, uri: StorageUri, format: str, product_name: str) -> ProductIdentity:
39
+ """Create a ProductIdentity from a URI with an explicit product name.
40
+
41
+ The product_name must be provided explicitly; it is not derived from the
42
+ URI basename. Use CLI --product-name, config default_product_name, or
43
+ plugin PRODUCT_NAME as the source.
44
+ """
45
+ if not product_name:
46
+ raise ValueError(f"product_name is required; cannot be empty. URI: {uri}")
47
+ control_root = uri.join(".firecube")
48
+ return cls(
49
+ product_name=product_name,
50
+ product_uri=uri,
51
+ control_root_uri=control_root,
52
+ format=format,
53
+ )
54
+
55
+
56
+ def ensure_product_uri(base_uri: str, product: str) -> str:
57
+ """Append `product` to `base_uri` if the last path segment doesn't exactly match.
58
+
59
+ Uses URI path parsing to avoid substring false positives (e.g. 'daily' matching
60
+ 'daily_archive').
61
+ """
62
+ from firecube.core.uris import parse_uri
63
+
64
+ base_uri = str(base_uri or "").rstrip("/")
65
+ product = str(product or "")
66
+
67
+ if not product:
68
+ return base_uri
69
+
70
+ parsed = parse_uri(base_uri)
71
+ path = parsed.get("path", "").rstrip("/")
72
+ last_segment = path.rsplit("/", 1)[-1] if "/" in path else path
73
+ if last_segment == product:
74
+ return base_uri
75
+
76
+ return base_uri + "/" + product
@@ -0,0 +1,121 @@
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 enum import StrEnum
19
+ from pathlib import Path
20
+
21
+ from firecube.core.product.identity import ProductIdentity, ensure_product_uri
22
+ from firecube.core.storage.uri import StorageUri
23
+ from firecube.core.uris import (
24
+ is_remote_target,
25
+ is_windows_absolute_path,
26
+ )
27
+
28
+
29
+ class CompletionRoute(StrEnum):
30
+ DIRECT = "direct"
31
+ STAGED = "staged"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class WriteModePolicy:
36
+ name: str
37
+ resolves_to_workspace: bool
38
+ seeds_staged_metadata: bool
39
+ storage_handled_by_engine: bool
40
+ completion_route: CompletionRoute
41
+
42
+
43
+ STAGED_WRITE_MODE = WriteModePolicy(
44
+ name="staged",
45
+ resolves_to_workspace=True,
46
+ seeds_staged_metadata=True,
47
+ storage_handled_by_engine=False,
48
+ completion_route=CompletionRoute.STAGED,
49
+ )
50
+ DIRECT_WRITE_MODE = WriteModePolicy(
51
+ name="direct",
52
+ resolves_to_workspace=False,
53
+ seeds_staged_metadata=False,
54
+ storage_handled_by_engine=True,
55
+ completion_route=CompletionRoute.DIRECT,
56
+ )
57
+
58
+ _WRITE_MODE_POLICIES = {
59
+ STAGED_WRITE_MODE.name: STAGED_WRITE_MODE,
60
+ DIRECT_WRITE_MODE.name: DIRECT_WRITE_MODE,
61
+ }
62
+
63
+
64
+ def write_mode_policy(write_mode: str | WriteModePolicy) -> WriteModePolicy:
65
+ if isinstance(write_mode, WriteModePolicy):
66
+ return write_mode
67
+ try:
68
+ return _WRITE_MODE_POLICIES[str(write_mode)]
69
+ except KeyError as exc:
70
+ valid = ", ".join(sorted(_WRITE_MODE_POLICIES))
71
+ raise ValueError(f"Unknown write mode {write_mode!r}; expected one of: {valid}") from exc
72
+
73
+
74
+ class ProductResolver:
75
+ @staticmethod
76
+ def resolve(target: str, format: str, product_name: str) -> ProductIdentity:
77
+ """Boundary-only: parse target string into typed ProductIdentity."""
78
+ try:
79
+ uri = StorageUri.parse(target)
80
+ except ValueError as e:
81
+ msg = str(e).lower()
82
+ if "scheme" in msg or "file://" in msg or "bare paths" in msg:
83
+ raise ValueError(
84
+ "--target must be a full URI like 's3://bucket/path/x.zarr' or "
85
+ "'file:///abs/path/x.zarr'. Bare names and relative paths are rejected. "
86
+ f"Original error: {e}"
87
+ ) from e
88
+ raise
89
+ return ProductIdentity.from_uri(uri, format=format, product_name=product_name)
90
+
91
+
92
+ def resolve_dataset_target(
93
+ target: str,
94
+ *,
95
+ write_mode: str | WriteModePolicy = "staged",
96
+ temp_root: Path | None = None,
97
+ direct_base_uri: str | None = None,
98
+ ) -> str:
99
+ """Resolve the URI for a dataset target (Dataset Directory).
100
+
101
+ This determines the "Package Root" for Zarr/Parquet output, considering:
102
+ 1. Absolute/Full URIs (s3://, /abs/path) -> Used as-is.
103
+ 2. Relative paths + Staged mode -> Anchored to temporary workspace.
104
+ 3. Relative paths + Direct mode -> Anchored to the typed product URI's parent.
105
+ """
106
+ if (
107
+ is_remote_target(target)
108
+ or Path(target).is_absolute() # firecube: STORAGE-URI
109
+ or target.startswith("file://")
110
+ or is_windows_absolute_path(target)
111
+ ):
112
+ return target
113
+
114
+ if write_mode_policy(write_mode).resolves_to_workspace:
115
+ workspace = temp_root if temp_root else Path.cwd()
116
+ return str(workspace / target)
117
+
118
+ if direct_base_uri:
119
+ return ensure_product_uri(direct_base_uri, target)
120
+
121
+ return target
@@ -0,0 +1,120 @@
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 pathlib import Path
19
+
20
+ from firecube.core.storage.driver_config import (
21
+ StorageDriverConfig,
22
+ )
23
+ from firecube.core.storage.uri import StorageUri
24
+ from firecube.core.uris import parse_uri
25
+
26
+ _OBSTORE_SUPPORTED_PROTOCOLS = {"s3", "file", "gs", "az"}
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ResolvedProduct:
31
+ product_name: str
32
+ product_uri: StorageUri
33
+ output_base_uri: StorageUri
34
+ control_root_uri: StorageUri
35
+ format: str
36
+
37
+
38
+ class ProductTarget:
39
+ @classmethod
40
+ def resolve(
41
+ cls,
42
+ target_arg: str,
43
+ driver_config: StorageDriverConfig,
44
+ *,
45
+ product_name: str,
46
+ plugin_default_format: str | None = None,
47
+ default_base_uri: StorageUri | None = None,
48
+ ) -> ResolvedProduct:
49
+ target_value = str(target_arg or "")
50
+ if not product_name:
51
+ raise ValueError("product_name is required; cannot be empty.")
52
+ output_format = str(plugin_default_format or "zarr")
53
+ parsed = parse_uri(target_value)
54
+ protocol = parsed["protocol"]
55
+
56
+ if cls._is_bare_name_target(target_value, protocol):
57
+ if default_base_uri is None:
58
+ raise ValueError(
59
+ "Bare-name targets require either a fully-qualified URI/path or default_base_uri."
60
+ )
61
+
62
+ product_uri = default_base_uri.join(target_value)
63
+ cls._validate_driver_protocol(driver_config, product_uri.protocol)
64
+ return ResolvedProduct(
65
+ product_name=product_name,
66
+ product_uri=product_uri,
67
+ output_base_uri=default_base_uri,
68
+ control_root_uri=cls._control_root(product_uri),
69
+ format=output_format,
70
+ )
71
+
72
+ if protocol != "file":
73
+ product_uri = StorageUri.parse(target_value)
74
+ cls._validate_driver_protocol(driver_config, product_uri.protocol)
75
+ return ResolvedProduct(
76
+ product_name=product_name,
77
+ product_uri=product_uri,
78
+ output_base_uri=product_uri.parent(),
79
+ control_root_uri=cls._control_root(product_uri),
80
+ format=output_format,
81
+ )
82
+
83
+ if target_value.startswith("file://"):
84
+ product_uri = StorageUri.parse(target_value)
85
+ resolved_path = Path(product_uri.path).resolve()
86
+ else:
87
+ resolved_path = Path(target_value).resolve()
88
+ product_uri = StorageUri.from_local_path(resolved_path)
89
+ cls._validate_driver_protocol(driver_config, product_uri.protocol)
90
+ return ResolvedProduct(
91
+ product_name=product_name,
92
+ product_uri=product_uri,
93
+ output_base_uri=product_uri.parent(),
94
+ control_root_uri=cls._control_root(product_uri),
95
+ format=output_format,
96
+ )
97
+
98
+ @staticmethod
99
+ def _is_bare_name_target(target_arg: str, protocol: str) -> bool:
100
+ return (
101
+ protocol == "file"
102
+ and "/" not in target_arg
103
+ and "\\" not in target_arg
104
+ and not target_arg.startswith("file://")
105
+ )
106
+
107
+ @staticmethod
108
+ def _control_root(product_uri: StorageUri) -> StorageUri:
109
+ return product_uri.join(".firecube")
110
+
111
+ @staticmethod
112
+ def _validate_driver_protocol(
113
+ driver_config: StorageDriverConfig,
114
+ protocol: str,
115
+ ) -> None:
116
+ if driver_config.driver == "obstore" and protocol not in _OBSTORE_SUPPORTED_PROTOCOLS:
117
+ raise ValueError(
118
+ f"Storage driver 'obstore' does not support protocol '{protocol}'; "
119
+ "use --storage-driver=fsspec instead."
120
+ )
@@ -0,0 +1,187 @@
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
+ """Runtime wiring and global state for Firecube configuration.
16
+
17
+ This module handles side-effects like setting global variables and exporting
18
+ environment variables. It depends on `firecube.core.config` for types
19
+ and loading logic.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ from collections.abc import Mapping, MutableMapping
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from firecube.core.config import (
31
+ StorageConfig,
32
+ build_storage_config,
33
+ derive_target_uri,
34
+ load_config_file,
35
+ )
36
+ from firecube.core.product.identity import ProductIdentity
37
+ from firecube.core.storage.uri import StorageUri
38
+
39
+ log = logging.getLogger("firecube.core.runtime")
40
+
41
+ _global_storage_config: StorageConfig | None = None
42
+
43
+
44
+ def _target_uri_from_config(identity: ProductIdentity) -> str:
45
+ """Render the FIRECUBE_TARGET_URI value from a `ProductIdentity`.
46
+
47
+ Preserves legacy env-var semantics: bare local path for `file://` URIs,
48
+ canonical URI form for remote (`s3://`, `gs://`) URIs.
49
+ """
50
+ if identity.product_uri.protocol == "file":
51
+ return identity.product_uri.path
52
+ return identity.product_uri.to_str()
53
+
54
+
55
+ def set_global_storage_config(config: StorageConfig) -> None:
56
+ """Set the global storage configuration."""
57
+ global _global_storage_config
58
+ config.validate()
59
+ _global_storage_config = config
60
+
61
+
62
+ def get_global_storage_config() -> StorageConfig:
63
+ """Return the global storage configuration."""
64
+ if _global_storage_config is None:
65
+ raise RuntimeError("Global storage configuration not set. Initialise with CLI args first.")
66
+ return _global_storage_config
67
+
68
+
69
+ def export_storage_config_to_env(
70
+ config: StorageConfig,
71
+ identity: ProductIdentity | None = None,
72
+ *,
73
+ env: MutableMapping[str, str] | None = None,
74
+ ) -> None:
75
+ """Publish storage configuration to environment variables for child processes.
76
+
77
+ Parameters
78
+ ----------
79
+ config:
80
+ Non-location storage config (storage_type, endpoint_url, credentials,
81
+ region, path_style, storage_driver).
82
+ identity:
83
+ Optional product identity providing location fields (bucket /
84
+ target_path / target_uri). When ``None``, location vars are omitted.
85
+ env:
86
+ Optional mutable mapping to write into; defaults to ``os.environ``.
87
+
88
+ Notes
89
+ -----
90
+ We intentionally export both FIRECUBE_* and AWS_* style variables:
91
+ - FIRECUBE_* is used by Firecube internals and helpers.
92
+ - AWS_* allows libraries such as fsspec/xarray/zarr to discover
93
+ credentials and endpoints when working with S3-compatible storage.
94
+ """
95
+ target_env: MutableMapping[str, str] = os.environ if env is None else env
96
+
97
+ target_env["FIRECUBE_STORAGE_TYPE"] = config.storage_type
98
+ if config.endpoint_url:
99
+ target_env["FIRECUBE_ENDPOINT_URL"] = config.endpoint_url
100
+ if config.access_key:
101
+ target_env["FIRECUBE_ACCESS_KEY"] = config.access_key
102
+ if config.secret_key:
103
+ target_env["FIRECUBE_SECRET_KEY"] = config.secret_key
104
+ if config.region:
105
+ target_env["FIRECUBE_REGION"] = config.region
106
+ target_env["FIRECUBE_PATH_STYLE"] = str(config.path_style).lower()
107
+ target_env["FIRECUBE_STORAGE_DRIVER"] = config.storage_driver
108
+
109
+ if identity is not None:
110
+ if identity.product_uri.authority:
111
+ target_env["FIRECUBE_BUCKET"] = identity.product_uri.authority
112
+ if identity.product_uri.protocol == "file":
113
+ target_env["FIRECUBE_TARGET_PATH"] = identity.product_uri.path
114
+ target_env["FIRECUBE_TARGET_URI"] = _target_uri_from_config(identity)
115
+
116
+ if config.access_key:
117
+ target_env["AWS_ACCESS_KEY_ID"] = config.access_key
118
+ if config.secret_key:
119
+ target_env["AWS_SECRET_ACCESS_KEY"] = config.secret_key
120
+ if config.region:
121
+ target_env["AWS_DEFAULT_REGION"] = config.region
122
+ if config.endpoint_url:
123
+ target_env["AWS_ENDPOINT_URL"] = config.endpoint_url
124
+ target_env["AWS_S3_ADDRESSING_STYLE"] = "path" if config.path_style else "virtual"
125
+
126
+
127
+ def identity_from_storage_config(storage_config: StorageConfig) -> ProductIdentity | None:
128
+ """Synthesize a `ProductIdentity` from a resolved `StorageConfig`.
129
+
130
+ Bridges the legacy bridge-attr storage view (``target_path`` / ``bucket``
131
+ set on ``StorageConfig`` by ``build_storage_config``) to the
132
+ ``ProductIdentity`` shape consumed by env-export and CLI bridge sites.
133
+ The remaining bridge-attr reads live inside ``derive_target_uri`` (see
134
+ config.py); this helper is the single seam between resolved config and
135
+ identity.
136
+
137
+ Returns ``None`` when the resolved config lacks a base URI (e.g. local
138
+ storage_type with no ``target_path`` set yet).
139
+ """
140
+ try:
141
+ base_uri_str = derive_target_uri(storage_config)
142
+ except ValueError:
143
+ return None
144
+
145
+ if storage_config.storage_type == "local":
146
+ base_uri = StorageUri.from_local_path(base_uri_str)
147
+ else:
148
+ base_uri = StorageUri.parse(base_uri_str)
149
+
150
+ name = base_uri.path.rstrip("/").rsplit("/", 1)[-1]
151
+ return ProductIdentity(
152
+ product_name=name,
153
+ product_uri=base_uri,
154
+ control_root_uri=base_uri.join(".firecube"),
155
+ format="zarr",
156
+ )
157
+
158
+
159
+ def resolve_storage_config(
160
+ *,
161
+ config_file: Path | str | None = None,
162
+ env: Mapping[str, str] = os.environ, # type: ignore[assignment]
163
+ overrides: dict[str, Any] | None = None,
164
+ export_env: bool = True,
165
+ set_global: bool = True,
166
+ ) -> StorageConfig:
167
+ """Resolve StorageConfig from config.toml + env + overrides.
168
+
169
+ This is a convenience wrapper used by CLI entrypoints to keep the
170
+ load/build/validate/export wiring consistent across commands.
171
+
172
+ Notes
173
+ -----
174
+ - Precedence is implemented in `build_storage_config(...)`.
175
+ - This helper does not depend on click (so it can be used by non-CLI code).
176
+ """
177
+ cfg = load_config_file(config_file)
178
+ storage_config = build_storage_config(cfg, env, overrides or {})
179
+ storage_config.validate()
180
+
181
+ if set_global:
182
+ set_global_storage_config(storage_config)
183
+ if export_env:
184
+ identity = identity_from_storage_config(storage_config)
185
+ export_storage_config_to_env(storage_config, identity)
186
+
187
+ return storage_config