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,836 @@
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
+ """Base implementation for Firecube ingestors.
16
+
17
+ This module provides the `BaseIngestor` abstract base class which orchestrates
18
+ ingestion using composition of runtime services.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import inspect
25
+ import logging
26
+ from abc import ABC, abstractmethod
27
+ from collections.abc import Iterable, Mapping, Sequence
28
+ from pathlib import Path
29
+ from typing import Any, ClassVar, cast, get_type_hints
30
+
31
+ from firecube.core.controlplane import ChunkManager
32
+ from firecube.core.controlplane.metrics import collect_wal_metrics
33
+ from firecube.core.filesystem import collect_filesystem_metrics
34
+ from firecube.core.formats import discover_input_files
35
+ from firecube.core.intake import CatalogGroupInfo
36
+ from firecube.core.observability import create_ingestion_telemetry
37
+ from firecube.core.observability.metrics import TelemetryService
38
+ from firecube.core.product.identity import ProductIdentity
39
+ from firecube.core.storage.binding import StorageBinding
40
+ from firecube.core.storage.driver_config import StorageDriverConfig
41
+ from firecube.core.storage.uri import StorageUri
42
+ from firecube.ingestor.config.engine import SYSTEM_KEYS, EngineConfig, config_keys
43
+ from firecube.ingestor.contracts.interfaces import Ingestor, is_dataset_producer
44
+ from firecube.ingestor.errors import ConfigurationError
45
+ from firecube.ingestor.runtime.aggregation import merge_batch_metrics
46
+ from firecube.ingestor.runtime.base_hooks import BaseIngestorHookMixin
47
+ from firecube.ingestor.runtime.batching import BatchPlanHost, BatchPlanner
48
+ from firecube.ingestor.runtime.configure import (
49
+ ExecutionMode,
50
+ TierConfigurator,
51
+ determine_execution_mode,
52
+ ensure_chunk_manager_config,
53
+ ensure_run_id,
54
+ )
55
+ from firecube.ingestor.runtime.engine import run_sequential
56
+ from firecube.ingestor.runtime.parallel_execution_state import _ParallelExecutionState
57
+ from firecube.ingestor.runtime.parallel_run_id import derive_pod_run_id
58
+ from firecube.ingestor.runtime.preflight import preflight
59
+ from firecube.ingestor.runtime.recording import SpanRecorder
60
+ from firecube.ingestor.runtime.resume_guard import ResumeGuard
61
+ from firecube.ingestor.runtime.workspace import LocalSourceFile, WorkspaceManager
62
+ from firecube.ingestor.runtime.zarr.existing_cube_check import verify_dim_compatibility
63
+ from firecube.ingestor.templates.config import TemplateConfig
64
+ from firecube.ingestor.types.config import PluginConfig
65
+ from firecube.ingestor.types.context import (
66
+ IngestContext,
67
+ IngestResult,
68
+ PipelineBatch,
69
+ PipelineResult,
70
+ PipelineRunState,
71
+ PluginContext,
72
+ RuntimeFlags,
73
+ RuntimeIngestContext,
74
+ )
75
+ from firecube.ingestor.types.result_metrics import OutputPaths
76
+ from firecube.ingestor.validation import validate_config_collisions, validate_product_name_contract
77
+
78
+ __all__ = [
79
+ "BaseIngestor",
80
+ "LocalSourceFile",
81
+ ]
82
+
83
+
84
+ def _output_storage_session(
85
+ ctx: IngestContext | RuntimeIngestContext | PluginContext,
86
+ ) -> Any | None:
87
+ return storage.output if (storage := getattr(ctx, "storage", None)) is not None else None
88
+
89
+
90
+ def _output_product_name(
91
+ ctx: IngestContext | RuntimeIngestContext | PluginContext, default: str
92
+ ) -> str:
93
+ if (session := _output_storage_session(ctx)) is not None:
94
+ return str(session.product.product_name)
95
+ return default
96
+
97
+
98
+ def _maybe_run_storage_preflight(
99
+ runtime_ctx: RuntimeIngestContext, engine_config: EngineConfig
100
+ ) -> None:
101
+ if engine_config.skip_preflight:
102
+ return
103
+
104
+ storage = runtime_ctx.storage
105
+ if storage is not None and storage.output is not None:
106
+ preflight(storage.output)
107
+
108
+
109
+ def _select_output_format_strategy(ingestor: Any, ctx: IngestContext) -> None:
110
+ """Wire output-format-specific template config and batch strategy."""
111
+ if str(ctx.output_format or "").lower() != "tensogram":
112
+ return
113
+ if not is_dataset_producer(type(ingestor)):
114
+ raise ConfigurationError(
115
+ f"{type(ingestor).__name__} does not implement DatasetProducer; "
116
+ "cannot use output_format='tensogram'."
117
+ )
118
+
119
+ from firecube.ingestor.runtime.configure import TierConfigurator
120
+ from firecube.ingestor.templates.generic_tensogram import bind_tensogram_strategy
121
+
122
+ bind_tensogram_strategy(ingestor)
123
+ routed_ingestor = cast(Any, ingestor)
124
+ routed_ingestor._configurator = TierConfigurator(
125
+ routed_ingestor.template_config_class,
126
+ routed_ingestor.plugin_config_class,
127
+ plugin_name=routed_ingestor.name,
128
+ )
129
+
130
+
131
+ def _storage_uri_from_raw(raw: str) -> StorageUri:
132
+ if "://" in raw:
133
+ return StorageUri.parse(raw)
134
+ return StorageUri.from_local_path(Path(raw).expanduser().resolve())
135
+
136
+
137
+ def _minimal_binding(product: str, fmt: str = "zarr") -> StorageBinding:
138
+ import tempfile
139
+
140
+ root = Path(tempfile.mkdtemp(prefix="firecube_controlplane_"))
141
+ uri = StorageUri.from_local_path((root / product).resolve())
142
+ return StorageBinding(
143
+ identity=ProductIdentity.from_uri(uri, fmt, product_name=product),
144
+ driver=StorageDriverConfig.from_storage_config_or_default(None),
145
+ )
146
+
147
+
148
+ def _binding_from_context(ctx: IngestContext, product: str) -> StorageBinding:
149
+ session = _output_storage_session(ctx)
150
+ if session is not None:
151
+ return StorageBinding(identity=session.product, driver=session.driver)
152
+
153
+ fmt = ctx.output_format or "zarr"
154
+ if ctx.target:
155
+ return StorageBinding(
156
+ identity=ProductIdentity.from_uri(
157
+ _storage_uri_from_raw(str(ctx.target)),
158
+ fmt,
159
+ product_name=product,
160
+ ),
161
+ driver=StorageDriverConfig.from_storage_config_or_default(None),
162
+ )
163
+
164
+ return _minimal_binding(product, fmt)
165
+
166
+
167
+ class BaseIngestor(BaseIngestorHookMixin, Ingestor, ABC):
168
+ """Orchestrating base class for all Firecube ingestors.
169
+
170
+ ``BaseIngestor`` is a composition facade. It wires together the runtime
171
+ services (batching, telemetry, recording, workspace, resume-guard) and
172
+ exposes a hook surface so that plugin authors only need to implement their
173
+ domain logic.
174
+
175
+ ``run()`` delegates to:
176
+ - ``BatchPlanner`` — creates ``PipelineBatch`` objects from items.
177
+ - ``TierConfigurator`` — splits flat options into EngineConfig /
178
+ TemplateConfig / PluginConfig options tiers
179
+ (not storage layers; storage I/O wiring is
180
+ handled by StorageBinding / StorageSession).
181
+ - ``WorkspaceManager`` — manages per-run temp directories.
182
+ - ``ResumeGuard`` — enforces resume / overwrite safety.
183
+ - ``TelemetryService`` — wraps the telemetry sink with metric limits.
184
+ - ``SpanRecorder`` — writes manifest entries to ChunkManager.
185
+ - ``PipelineExecutor`` — parallel or sequential batch dispatch.
186
+
187
+ Hooks fall into three categories. The class-variable ``name`` is also
188
+ required.
189
+
190
+ MUST override (abstract):
191
+ ``_process_batch(batch, ctx) -> PipelineResult``
192
+ Core per-batch logic. Called in worker threads for parallel mode.
193
+ Templates (``GenericZarrIngestor``, ``GenericParquetIngestor``)
194
+ implement this; plain plugins may override it directly.
195
+
196
+ SHOULD override (return empty defaults; rarely correct without change):
197
+ ``discover_source_files(ctx) -> Iterable``
198
+ Discovers source files to be batched (paths/URIs/objects) using
199
+ configured include patterns.
200
+
201
+ CAN override (optional lifecycle / metadata hooks):
202
+ ``filter_item(item, ctx) -> bool``
203
+ Per-item filter applied before batching. Default: keep all.
204
+ ``item_size_bytes(item) -> int | None``
205
+ Used for batch size estimation. Default: ``None``.
206
+ ``get_batch_groups(items, ctx) -> list[str]``
207
+ Derives logical output groups for a batch. Default: ``['default']``.
208
+ ``slice_meta_keys() -> list[str]``
209
+ Option keys that identify a logical "slice" (used for resume
210
+ conflict detection). Default: empty list.
211
+ ``validation_group(ctx) -> str | None``
212
+ Zarr group path used by the resume-guard's ``validate_zarr`` check.
213
+ ``on_pipeline_start(ctx, state)``
214
+ Called once before any batch runs. Useful for shared resource
215
+ setup (e.g. persistent DuckDB schema).
216
+ ``on_batch_success(ctx, state, batch, result)``
217
+ Called on the main thread after each successful batch.
218
+ ``on_batch_failure(ctx, state, batch, result)``
219
+ Called on the main thread after each failed batch.
220
+ DO NOT override (framework internals):
221
+ ``run(ctx)`` — top-level orchestration entry point.
222
+ ``_create_batches`` — delegates to ``BatchPlanner``.
223
+ ``finalize_pipeline`` — delegates to ``PipelineExecutor``.
224
+ """
225
+
226
+ PRODUCT_NAME: ClassVar[str]
227
+ # Firecube's append/index dimension name as written into the Zarr store.
228
+ # Default 'timestamp' preserves back-compat.
229
+ # Plugins override on their subclass for CF-1.8 conventional naming (e.g.
230
+ # `time_dim_name: ClassVar[str] = 'time'`). NOT a config-tier field,
231
+ # NOT exposed via --option.
232
+ time_dim_name: ClassVar[str] = "timestamp"
233
+ name: str
234
+
235
+ # Configuration Tiers (Declarative)
236
+ template_config_class: type[TemplateConfig] | None = None
237
+ plugin_config_class: type[PluginConfig] | None = None
238
+
239
+ # Instance Configs (Populated in run())
240
+ engine_config: EngineConfig
241
+ template_config: TemplateConfig | None = None
242
+ plugin_config: PluginConfig | None = None
243
+
244
+ def __init_subclass__(cls, **kwargs: Any) -> None:
245
+ """Build-time validation for ingestor class contracts."""
246
+ super().__init_subclass__(**kwargs)
247
+
248
+ validate_product_name_contract(cls)
249
+
250
+ e_keys = config_keys(EngineConfig)
251
+ t_keys = config_keys(cls.template_config_class) if cls.template_config_class else set()
252
+ p_keys = config_keys(cls.plugin_config_class) if cls.plugin_config_class else set()
253
+
254
+ if t_keys and p_keys:
255
+ validate_config_collisions(
256
+ dict.fromkeys(t_keys),
257
+ dict.fromkeys(p_keys),
258
+ scope=f"Template/Plugin Config in {cls.__name__}",
259
+ )
260
+
261
+ if t_keys:
262
+ validate_config_collisions(
263
+ dict.fromkeys(e_keys),
264
+ dict.fromkeys(t_keys),
265
+ scope=f"Engine/Template Config in {cls.__name__}",
266
+ )
267
+
268
+ if p_keys:
269
+ validate_config_collisions(
270
+ dict.fromkeys(e_keys),
271
+ dict.fromkeys(p_keys),
272
+ scope=f"Engine/Plugin Config in {cls.__name__}",
273
+ )
274
+
275
+ def _resolve_time_dim_name(self) -> str:
276
+ return type(self).time_dim_name
277
+
278
+ @classmethod
279
+ def describe_options(cls) -> dict[str, list[str]]:
280
+ """Return structured options documentation for CLI introspection."""
281
+ resolved_product_name = getattr(cls, "PRODUCT_NAME", None)
282
+ if isinstance(resolved_product_name, str) and resolved_product_name:
283
+ product_name_entry: list[str] = [resolved_product_name]
284
+ else:
285
+ product_name_entry = []
286
+ info = {
287
+ "Product Name": product_name_entry,
288
+ "Engine Options": sorted(config_keys(EngineConfig)),
289
+ "System Keys": sorted(SYSTEM_KEYS),
290
+ }
291
+ if cls.template_config_class:
292
+ info["Template Options"] = sorted(config_keys(cls.template_config_class))
293
+ if cls.plugin_config_class:
294
+ info["Plugin Options"] = sorted(config_keys(cls.plugin_config_class))
295
+ return info
296
+
297
+ def __init__(self, *, name: str | None = None, chunk_manager: ChunkManager | None = None):
298
+ self.name = name or getattr(self, "name", self.__class__.__name__.lower())
299
+ self._log = logging.getLogger(f"firecube.ingestor.{self.name}")
300
+
301
+ if chunk_manager is not None:
302
+ self._chunk_manager = chunk_manager
303
+ self._owns_chunk_manager = False
304
+ else:
305
+ self._chunk_manager = ChunkManager(binding=_minimal_binding(self.name))
306
+ self._owns_chunk_manager = True
307
+
308
+ # Composition
309
+ self._workspace = WorkspaceManager(self.name)
310
+ self._configurator = TierConfigurator(
311
+ self.template_config_class,
312
+ self.plugin_config_class,
313
+ plugin_name=self.name,
314
+ )
315
+ self._batch_planner = BatchPlanner()
316
+ self._span_recorder = SpanRecorder(self._chunk_manager)
317
+
318
+ self._firecube_engine = None # Lazy init for PipelineExecutor
319
+ self._parallel_execution_state: _ParallelExecutionState | None = None
320
+ # True once ensure_slot_index_model has succeeded in this pod process;
321
+ # fast-path in _ensure_slot_index_model_at_startup.
322
+ self._slot_index_model_stamped: bool = False
323
+
324
+ # --- BatchPlanHost Protocol Implementation ---
325
+
326
+ @property
327
+ def batch_id_prefix(self) -> str:
328
+ return f"{self.name}_"
329
+
330
+ def discover_source_files(self, ctx: PluginContext) -> Iterable[Any]:
331
+ """Discover source files matching include patterns."""
332
+ if not ctx.source:
333
+ raise ConfigurationError(
334
+ "--input-data is required when using default source discovery. "
335
+ "Override discover_source_files() in your plugin if your "
336
+ "ingestor does not consume an input path."
337
+ )
338
+ includes = None
339
+ if getattr(self, "engine_config", None) is not None:
340
+ includes = getattr(self.engine_config, "include_patterns", None)
341
+
342
+ files = discover_input_files(
343
+ ctx.source,
344
+ storage_config=self._chunk_manager.storage_config,
345
+ preferred_globs=includes,
346
+ recursive=True,
347
+ )
348
+
349
+ if not files:
350
+ self._log.warning("No input files found in %s", ctx.source)
351
+ return iter(())
352
+
353
+ self._log.info("Found %d files", len(files))
354
+ return files
355
+
356
+ def filter_item(self, item: Any, ctx: PluginContext) -> bool:
357
+ """Filter items before batching (Hook). Default: True."""
358
+ return True
359
+
360
+ def item_size_bytes(self, item: Any) -> int | None:
361
+ """Estimate size of an item in bytes."""
362
+ try:
363
+ return Path(item).stat().st_size
364
+ except (TypeError, OSError):
365
+ return None
366
+
367
+ def get_batch_groups(self, items: Sequence[Any], ctx: PluginContext) -> list[str]:
368
+ """Return logical write groups for a batch of items (Hook). Default: ['default']."""
369
+ return ["default"]
370
+
371
+ def batch_setup(self, ctx: PluginContext) -> None:
372
+ """Hook for per-batch setup (e.g. DB connections). Cooperatively calls super."""
373
+ # Intentional cooperative super call:
374
+ # this base can be used with or without mixins defining batch_setup().
375
+ # Missing parent hook is treated as a no-op by design.
376
+ super_setup = getattr(super(), "batch_setup", None)
377
+ if callable(super_setup):
378
+ super_setup(ctx)
379
+
380
+ def batch_teardown(self, ctx: PluginContext) -> None:
381
+ """Hook for per-batch cleanup. Cooperatively calls super."""
382
+ # Same cooperative behavior as batch_setup(); keep teardown chain optional.
383
+ super_teardown = getattr(super(), "batch_teardown", None)
384
+ if callable(super_teardown):
385
+ super_teardown(ctx)
386
+
387
+ def prepare_batch_data(self, batch: PipelineBatch, ctx: PluginContext) -> dict[str, Any] | None:
388
+ """Optional hook to prepare data (e.g. load files to DB) before group iteration."""
389
+ return None
390
+
391
+ def cleanup_batch_data(self, batch: PipelineBatch, ctx: PluginContext) -> None:
392
+ """Optional hook to clean up batch data (e.g. drop table rows)."""
393
+
394
+ def _materialize(self, source: Any) -> Path:
395
+ return self._workspace.materialize(source)
396
+
397
+ # --- PipelineHost Implementation (Facade) ---
398
+
399
+ def _create_batches(
400
+ self, ctx: RuntimeIngestContext, batch_size: int
401
+ ) -> Iterable[PipelineBatch]:
402
+ """Delegate batch creation to BatchPlanner."""
403
+ host = cast(BatchPlanHost, self)
404
+ for batch in self._batch_planner.create_batches(host, PluginContext(ctx), batch_size):
405
+ self._verify_existing_cube_batch_groups(ctx, batch.groups)
406
+ yield batch
407
+
408
+ def _verify_existing_cube_batch_groups(
409
+ self, ctx: RuntimeIngestContext, group_paths: Sequence[str]
410
+ ) -> None:
411
+ if str(ctx.output_format or "").lower() != "zarr":
412
+ return
413
+ verify_dim_compatibility(
414
+ target_uri=str(ctx.target or ""),
415
+ declared_dim=self._resolve_time_dim_name(),
416
+ group_paths=group_paths,
417
+ storage_config=self._chunk_manager.storage_config,
418
+ )
419
+
420
+ def finalize_pipeline(self, ctx: RuntimeIngestContext, state: PipelineRunState) -> IngestResult:
421
+ """Delegate finalization to PipelineExecutor."""
422
+ if self._firecube_engine:
423
+ return self._firecube_engine.finalize(ctx, state, self)
424
+
425
+ # Fallback if engine not permanently attached (e.g. strict sequential usage or tests)
426
+ from firecube.ingestor.runtime.engine import PipelineExecutor
427
+
428
+ return PipelineExecutor().finalize(ctx, state, self)
429
+
430
+ # --- Execution Logic ---
431
+
432
+ @abstractmethod
433
+ def _process_batch(self, batch: PipelineBatch, ctx: PluginContext) -> PipelineResult:
434
+ """Process a single batch. Called in worker threads for parallel execution.
435
+
436
+ Subclasses must implement this. Templates (``GenericZarrIngestor``,
437
+ ``GenericParquetIngestor``) provide a default implementation via their
438
+ own ``build_dataset`` abstract hook.
439
+ """
440
+
441
+ @staticmethod
442
+ def default_aggregate_metrics(
443
+ ctx: RuntimeIngestContext, state: PipelineRunState
444
+ ) -> dict[str, Any]:
445
+ """Default aggregate helper for plugins that do not need custom policy."""
446
+ return merge_batch_metrics(ctx, state)
447
+
448
+ def _aggregate_metrics(
449
+ self, ctx: RuntimeIngestContext, state: PipelineRunState
450
+ ) -> Mapping[str, Any]:
451
+ """Use standard batch-metric merge policy unless plugin overrides it."""
452
+ return merge_batch_metrics(ctx, state)
453
+
454
+ def _verify_schema_at_pod_startup(self, ctx: PluginContext) -> None:
455
+ """Optional lifecycle hook for per-pod schema verification before planning."""
456
+ _ = ctx
457
+ return
458
+
459
+ def _ensure_slot_index_model_at_startup(self, ctx: PluginContext) -> None:
460
+ """Optional lifecycle hook for per-pod slot-index model negotiation."""
461
+ _ = ctx
462
+ return
463
+
464
+ def _validate_context_hook_signatures(self) -> None:
465
+ """Enforce PluginContext vs RuntimeIngestContext boundary on subclass hooks.
466
+
467
+ Plugin-facing hooks must annotate ``ctx: PluginContext``.
468
+ Runtime-finalization hooks must annotate ``ctx: RuntimeIngestContext``.
469
+ Mismatches raise ConfigurationError with the expected type.
470
+ """
471
+
472
+ checks: dict[str, type[Any]] = {
473
+ "discover_source_files": PluginContext,
474
+ "filter_item": PluginContext,
475
+ "batch_setup": PluginContext,
476
+ "batch_teardown": PluginContext,
477
+ "prepare_batch_data": PluginContext,
478
+ "cleanup_batch_data": PluginContext,
479
+ "build_dataset": PluginContext,
480
+ "get_batch_groups": PluginContext,
481
+ "get_zarr_config": PluginContext,
482
+ "output_relpath": PluginContext,
483
+ "write_parquet": PluginContext,
484
+ "prepare_duckdb_schema": PluginContext,
485
+ "slice_meta": PluginContext,
486
+ "validation_group": PluginContext,
487
+ "on_pipeline_start": PluginContext,
488
+ "on_batch_success": PluginContext,
489
+ "on_batch_failure": PluginContext,
490
+ "_process_batch": PluginContext,
491
+ "_aggregate_metrics": RuntimeIngestContext,
492
+ "finalize_pipeline": RuntimeIngestContext,
493
+ }
494
+
495
+ cls = type(self)
496
+ for method_name, expected_ctx_type in checks.items():
497
+ method = getattr(cls, method_name, None)
498
+ if method is None or not callable(method):
499
+ continue
500
+
501
+ try:
502
+ sig = inspect.signature(method)
503
+ except (TypeError, ValueError):
504
+ continue
505
+ if "ctx" not in sig.parameters:
506
+ continue
507
+
508
+ try:
509
+ hints = get_type_hints(method)
510
+ except Exception:
511
+ hints = {}
512
+ ctx_hint = hints.get("ctx")
513
+ if ctx_hint is None:
514
+ continue
515
+
516
+ if expected_ctx_type is PluginContext and ctx_hint is RuntimeIngestContext:
517
+ raise ConfigurationError(
518
+ f"{cls.__name__}.{method_name} must use PluginContext, not RuntimeIngestContext."
519
+ )
520
+ if expected_ctx_type is RuntimeIngestContext and ctx_hint is PluginContext:
521
+ raise ConfigurationError(
522
+ f"{cls.__name__}.{method_name} must use RuntimeIngestContext, not PluginContext."
523
+ )
524
+
525
+ # --- Orchestration ---
526
+
527
+ def run(self, ctx: IngestContext) -> IngestResult:
528
+ """Orchestrate the ingestion.
529
+
530
+ The caller-provided ``IngestContext`` is treated as immutable input.
531
+ Runtime-only fields are carried in an internal ``RuntimeIngestContext``
532
+ clone for the duration of this run.
533
+
534
+ ``ctx.storage.output`` must already be populated by the caller
535
+ (CLI/SDK boundary) before invoking ``run()``; this method does not
536
+ auto-construct sessions.
537
+ """
538
+ if ctx.storage is None or ctx.storage.output is None:
539
+ raise ValueError(
540
+ "IngestContext.storage.output must be set before calling run(). "
541
+ "Use ProductResolver + StorageBinding + StorageSession to build "
542
+ "the context at the CLI/SDK boundary."
543
+ )
544
+
545
+ self._validate_context_hook_signatures()
546
+
547
+ _select_output_format_strategy(self, ctx)
548
+
549
+ product = _output_product_name(ctx, self.name)
550
+ if self._owns_chunk_manager:
551
+ self._chunk_manager = ChunkManager(binding=_binding_from_context(ctx, product))
552
+ self._span_recorder = SpanRecorder(self._chunk_manager)
553
+ ensure_chunk_manager_config(self._chunk_manager, ctx)
554
+ self._workspace._storage_config = self._chunk_manager.storage_config
555
+
556
+ # 1. Workspace Cleanup/Setup
557
+ workspace_root = self._workspace.setup(ctx)
558
+
559
+ # Resolve run_id without mutating the caller context.
560
+ _base_run_id = ensure_run_id(ctx=ctx, plugin_name=self.name)
561
+
562
+ # Create a runtime-enriched copy for use within this run.
563
+ runtime_ctx = RuntimeIngestContext.from_ingest_context(
564
+ ctx,
565
+ run_id=_base_run_id,
566
+ temp_root=workspace_root,
567
+ materializer=self._materialize,
568
+ )
569
+ runtime_plugin_ctx = PluginContext(runtime_ctx)
570
+
571
+ # 2. Configuration
572
+ self.engine_config, self.template_config, self.plugin_config = self._configurator.configure(
573
+ runtime_ctx
574
+ )
575
+ runtime_ctx.flags = RuntimeFlags(
576
+ force_reingest=bool(self.engine_config.force_reingest),
577
+ incremental=bool(self.engine_config.incremental),
578
+ dry_run=bool(self.engine_config.dry_run),
579
+ )
580
+
581
+ # 2a. Derive slot-aware run_id for parallel mode (single-pod returns base unchanged).
582
+ run_id = derive_pod_run_id(
583
+ _base_run_id,
584
+ self.engine_config.slot_start,
585
+ self.engine_config.slot_end,
586
+ slot_group=self.engine_config.slot_group,
587
+ )
588
+ if run_id != _base_run_id:
589
+ runtime_ctx.run_id = run_id
590
+ runtime_ctx.identity.run_id = run_id
591
+ runtime_ctx.options["run_id"] = run_id
592
+ runtime_plugin_ctx = PluginContext(runtime_ctx)
593
+
594
+ _maybe_run_storage_preflight(runtime_ctx, self.engine_config)
595
+
596
+ # 3. Telemetry Setup
597
+ # BaseIngestor always uses TelemetryService for unified limits
598
+ if getattr(runtime_ctx, "telemetry", None) is None:
599
+ runtime_ctx.telemetry = create_ingestion_telemetry(
600
+ plugin=self.name,
601
+ product=product,
602
+ output_format=str(runtime_ctx.output_format or ""),
603
+ write_mode=str(getattr(self.engine_config, "write_mode", "")),
604
+ run_id=run_id,
605
+ base_meta=self.slice_meta(runtime_plugin_ctx),
606
+ )
607
+ assert runtime_ctx.telemetry is not None
608
+
609
+ telemetry_service = TelemetryService(runtime_ctx.telemetry, self.name)
610
+ telemetry_service.start(run_id)
611
+ fs_metrics = wal_metrics = None
612
+ resume_guard: ResumeGuard | None = None
613
+ execution_result = IngestResult(
614
+ outputs=OutputPaths(primary=str(runtime_ctx.target or "")),
615
+ output_format=str(runtime_ctx.output_format or ""),
616
+ )
617
+ run_started_recorded = False
618
+
619
+ try:
620
+ ingest_span = (
621
+ runtime_ctx.telemetry.span(
622
+ "firecube.ingest",
623
+ attributes={
624
+ "firecube.run_id": run_id,
625
+ "firecube.plugin": self.name,
626
+ "firecube.product": product,
627
+ },
628
+ )
629
+ or contextlib.nullcontext()
630
+ )
631
+ with (
632
+ collect_filesystem_metrics() as fs_metrics,
633
+ collect_wal_metrics() as wal_metrics,
634
+ ingest_span,
635
+ ):
636
+ # 3.5 Capability gate for slot-range parallel ingestion
637
+ from firecube.ingestor.runtime.parallel_gate import validate_parallel_capability
638
+
639
+ parallel_global_schema = validate_parallel_capability(
640
+ ingestor=self,
641
+ slot_start=self.engine_config.slot_start,
642
+ slot_end=self.engine_config.slot_end,
643
+ ctx=runtime_plugin_ctx,
644
+ slot_group=self.engine_config.slot_group,
645
+ )
646
+ self._parallel_execution_state = (
647
+ _ParallelExecutionState(global_expected=parallel_global_schema)
648
+ if parallel_global_schema is not None
649
+ else None
650
+ )
651
+
652
+ # 4. Resume Guard
653
+ slot_range_for_record: tuple[int, int] | None = (
654
+ (self.engine_config.slot_start, self.engine_config.slot_end)
655
+ if self.engine_config.slot_start is not None
656
+ and self.engine_config.slot_end is not None
657
+ else None
658
+ )
659
+ guard = ResumeGuard(
660
+ plugin_name=self.name,
661
+ chunk_manager=self._chunk_manager,
662
+ log=self._log,
663
+ slice_meta_keys=self.slice_meta_keys(),
664
+ )
665
+ resume_guard = guard
666
+ guard.enforce(
667
+ ctx=runtime_ctx,
668
+ product=product,
669
+ slice_meta=self.slice_meta(runtime_plugin_ctx),
670
+ slot_range=slot_range_for_record,
671
+ slot_group=self.engine_config.slot_group,
672
+ validation_group=self.validation_group(runtime_plugin_ctx),
673
+ )
674
+
675
+ # 5. Runtime control-plane lifecycle
676
+ slice_meta = self.slice_meta(runtime_plugin_ctx)
677
+ slice_meta.setdefault("plugin", self.name)
678
+ self._span_recorder.register_run_started(
679
+ run_id=run_id,
680
+ product=product,
681
+ output_path=str(runtime_ctx.target or ""),
682
+ output_format=str(runtime_ctx.output_format or ""),
683
+ slice_meta=slice_meta,
684
+ slot_range=slot_range_for_record,
685
+ slot_group=self.engine_config.slot_group,
686
+ )
687
+ run_started_recorded = True
688
+
689
+ self._ensure_slot_index_model_at_startup(runtime_plugin_ctx)
690
+ self._verify_schema_at_pod_startup(runtime_plugin_ctx)
691
+
692
+ # 6. Planning
693
+ mode = determine_execution_mode(self.engine_config)
694
+ # Sequential falls back to pipeline with 0/1 workers pattern or distinct loop?
695
+ # Using BatchPlanner for both ensuring consistency.
696
+
697
+ if self._firecube_engine is None:
698
+ from firecube.ingestor.runtime.engine import PipelineExecutor
699
+
700
+ self._firecube_engine = PipelineExecutor()
701
+
702
+ execution_result: IngestResult
703
+
704
+ if mode == ExecutionMode.PIPELINE:
705
+ execution_result = self._firecube_engine.run_pipeline(runtime_ctx, host=self)
706
+ else:
707
+ state = run_sequential(
708
+ ctx=runtime_ctx,
709
+ host=self,
710
+ product=product,
711
+ batch_size=int(self.engine_config.pipeline_batch_size),
712
+ engine_config=self.engine_config,
713
+ log=self._log,
714
+ )
715
+ if not state.batches:
716
+ execution_result = IngestResult(
717
+ outputs=OutputPaths(primary=str(runtime_ctx.target)),
718
+ output_format=str(runtime_ctx.output_format or ""),
719
+ metrics={},
720
+ )
721
+ else:
722
+ execution_result = self._firecube_engine.finalize(runtime_ctx, state, self)
723
+
724
+ # 7. Output completion (storage write + manifest)
725
+ #
726
+ # Original ordering (buggy for staged uploads):
727
+ # if not execution_result.registered:
728
+ # self._span_recorder.register_run(...)
729
+ # execution_result = self._firecube_engine.complete_output(...)
730
+ #
731
+ # That let the WAL record terminal status="complete" before the
732
+ # staged upload actually finished. If complete_output() then raised,
733
+ # the exception path skipped register_run_failure() because
734
+ # execution_result.registered was already True.
735
+ execution_result = self._firecube_engine.complete_output(
736
+ execution_result, runtime_ctx, host=self
737
+ )
738
+
739
+ # 8. Recording
740
+ if not execution_result.registered:
741
+ self._span_recorder.register_run(
742
+ ctx=runtime_ctx,
743
+ result=execution_result,
744
+ run_id=run_id,
745
+ product=product,
746
+ slice_meta=slice_meta,
747
+ record_spans=not execution_result.spans_recorded,
748
+ )
749
+
750
+ return execution_result
751
+ except Exception as exc:
752
+ # Record a terminal failure only for runs that reached "started" but
753
+ # never reached a terminal WAL event. After the output-completion
754
+ # reorder above, execution_result.registered becomes True only after
755
+ # both complete_output() and register_run() succeed.
756
+ if run_started_recorded and not execution_result.registered:
757
+ with contextlib.suppress(Exception):
758
+ slice_meta = self.slice_meta(runtime_plugin_ctx)
759
+ slice_meta.setdefault("plugin", self.name)
760
+ self._span_recorder.register_run_failure(
761
+ run_id=run_id,
762
+ product=product,
763
+ output_path=str(runtime_ctx.target or ""),
764
+ output_format=str(runtime_ctx.output_format or ""),
765
+ slice_meta=slice_meta,
766
+ error=str(exc),
767
+ )
768
+ execution_result.registered = True
769
+ raise
770
+
771
+ finally:
772
+ # Teardown
773
+ should_cleanup = (
774
+ self.engine_config.cleanup_workspace if hasattr(self, "engine_config") else False
775
+ )
776
+ with contextlib.suppress(Exception):
777
+ # Engine finalization populates metrics["pipeline"] via
778
+ # compute_run_summary(); emit that canonical run snapshot once.
779
+ pipeline_summary = getattr(execution_result, "metrics", {}).get("pipeline")
780
+ if isinstance(pipeline_summary, dict):
781
+ if fs_metrics is not None:
782
+ pipeline_summary.update(fs_metrics.as_summary())
783
+ if wal_metrics is not None:
784
+ pipeline_summary.update(wal_metrics.as_summary())
785
+ if resume_guard is not None and resume_guard.last_metrics is not None:
786
+ pipeline_summary.update(resume_guard.last_metrics.as_summary())
787
+ telemetry_service.emit_run_metrics(pipeline_summary)
788
+ with contextlib.suppress(Exception):
789
+ telemetry_service.flush()
790
+ self._workspace.teardown(cleanup_dir=bool(should_cleanup))
791
+
792
+ def ingest(self, ctx: IngestContext) -> IngestResult:
793
+ """Compatibility entry point matching the public ``Ingestor`` protocol."""
794
+ return self.run(ctx)
795
+
796
+ def catalog_group_info(
797
+ self, group: str, store_uri: str, storage_config: Any | None = None
798
+ ) -> CatalogGroupInfo | None:
799
+ """Optionally annotate or hide one discovered catalog group."""
800
+ _ = (group, store_uri, storage_config)
801
+ return None
802
+
803
+ def resolve_output_uri(
804
+ self, ctx: RuntimeIngestContext | PluginContext, write_mode: str = "staged"
805
+ ) -> str:
806
+ """Resolve the canonical output URI (Dataset Directory) for this run."""
807
+ from firecube.core.product import resolve_dataset_target, write_mode_policy
808
+ from firecube.core.uris import is_remote_target
809
+
810
+ product = _output_product_name(ctx, self.name)
811
+ policy = write_mode_policy(write_mode)
812
+ if policy.resolves_to_workspace:
813
+ return resolve_dataset_target(product, write_mode=policy, temp_root=ctx.temp_root)
814
+
815
+ session = _output_storage_session(ctx)
816
+ if session is None:
817
+ raise ConfigurationError(
818
+ "Direct write mode requires ctx.storage.output to resolve the product root."
819
+ )
820
+ product_uri = session.product.product_uri
821
+ direct_base_uri = product_uri.parent().to_str()
822
+ expected_output_uri = product_uri.to_str()
823
+ control_plane_output_uri = self._chunk_manager.get_product_root(product)
824
+ if str(control_plane_output_uri).rstrip("/") != str(expected_output_uri).rstrip("/"):
825
+ raise ConfigurationError(
826
+ "ChunkManager control-plane is bound to a different output root than direct write "
827
+ f"resolution (expected={expected_output_uri}, bound={control_plane_output_uri})."
828
+ )
829
+ if is_remote_target(str(direct_base_uri)) and not is_remote_target(
830
+ str(expected_output_uri)
831
+ ):
832
+ raise ConfigurationError(
833
+ "Direct write mode resolved a remote output base into a local workspace path; "
834
+ f"expected remote output under {direct_base_uri}, got {expected_output_uri}."
835
+ )
836
+ return expected_output_uri