tangle-cli 0.0.1a1__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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1785 @@
|
|
|
1
|
+
"""Pipeline hydrator for expanding local Tangle pipeline YAML files.
|
|
2
|
+
|
|
3
|
+
This module is intentionally a close OSS port of
|
|
4
|
+
``tangle_deploy.pipeline_hydrator``. The generic reference-resolution code and
|
|
5
|
+
method names are preserved where possible so future upstream diffs are easy to
|
|
6
|
+
compare. Provider-specific infrastructure integrations are omitted, and
|
|
7
|
+
Docker/from-container materialization paths raise explicit unsupported errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from inspect import Parameter, signature
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from . import utils
|
|
26
|
+
from .api_transport import DEFAULT_TIMEOUT_SECONDS
|
|
27
|
+
from .component_generator import ComponentGenerator
|
|
28
|
+
from .handler import TangleCliHandler
|
|
29
|
+
from .hydration_trust import is_trusted_python_source, trusted_python_source_guidance
|
|
30
|
+
from .logger import Logger
|
|
31
|
+
from .utils import add_official_prefix
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .client import TangleApiClient
|
|
35
|
+
from .models import ComponentInfo
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HydrationError(ValueError):
|
|
39
|
+
"""Raised when a pipeline cannot be hydrated safely in OSS mode."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnsupportedHydrationFeatureError(HydrationError):
|
|
43
|
+
"""Raised for TD features intentionally excluded from the OSS CLI."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UntrustedHydrationSourceError(HydrationError):
|
|
47
|
+
"""Raised when hydration would execute an untrusted local source."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class HydratedPipeline:
|
|
52
|
+
"""Result returned by :meth:`PipelineHydrator.hydrate_file`."""
|
|
53
|
+
|
|
54
|
+
data: dict[str, Any]
|
|
55
|
+
content: str
|
|
56
|
+
resolved_count: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ResolverContext:
|
|
61
|
+
"""Structured context passed to component resolvers and URI hooks.
|
|
62
|
+
|
|
63
|
+
The legacy resolver signature ``(hydrator, value, path, base_dir)`` remains
|
|
64
|
+
supported. New downstream resolvers can accept a fifth ``context`` argument
|
|
65
|
+
to avoid reaching into hydrator internals for source/base/output/trust state.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
kind: str
|
|
69
|
+
value: Any
|
|
70
|
+
path: str
|
|
71
|
+
base_dir: Path | None
|
|
72
|
+
base_dirs: tuple[Path, ...]
|
|
73
|
+
source_path: Path | None = None
|
|
74
|
+
output_folder: Path | None = None
|
|
75
|
+
verbose: bool = False
|
|
76
|
+
trusted_python_sources: tuple[str, ...] = ()
|
|
77
|
+
allow_all_hydration: bool = False
|
|
78
|
+
error_policy: str = "warn"
|
|
79
|
+
resolution_overrides: Mapping[str, Any] | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
ComponentResolver = Callable[..., tuple[str, dict[str, Any]] | None]
|
|
83
|
+
UriReader = Callable[["PipelineHydrator", str, ResolverContext], str | None]
|
|
84
|
+
UriWriter = Callable[["PipelineHydrator", str, str, ResolverContext], None]
|
|
85
|
+
|
|
86
|
+
COMPONENT_RESOLVERS: dict[str, ComponentResolver] = {}
|
|
87
|
+
URI_READERS: dict[str, UriReader] = {}
|
|
88
|
+
URI_WRITERS: dict[str, UriWriter] = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def regenerate_yaml(**kwargs: Any) -> bool:
|
|
92
|
+
"""Generate a local Python component YAML.
|
|
93
|
+
|
|
94
|
+
Kept as a module-level seam for callers/tests that patch this operation.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
logger = kwargs.pop("logger", None)
|
|
98
|
+
verbose = bool(kwargs.pop("verbose", False))
|
|
99
|
+
return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml(**kwargs)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register_component_resolver(kind: str, resolver: ComponentResolver) -> None:
|
|
103
|
+
"""Register or replace a component resolver.
|
|
104
|
+
|
|
105
|
+
``kind`` is a reference kind or URI scheme such as ``file``, ``resolve``,
|
|
106
|
+
``http``, ``https``, ``name``, ``digest``, ``local``, or
|
|
107
|
+
``local_from_python``. Downstream packages can monkey-patch this registry;
|
|
108
|
+
for example, tangle-deploy can add ``local_from_docker`` without forking
|
|
109
|
+
the hydrator.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
COMPONENT_RESOLVERS[kind] = resolver
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def available_component_resolvers() -> list[str]:
|
|
116
|
+
"""Return registered resolver kinds in stable display order."""
|
|
117
|
+
|
|
118
|
+
return sorted(COMPONENT_RESOLVERS)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register_uri_reader(scheme: str, reader: UriReader) -> None:
|
|
122
|
+
"""Register a native-free URI reader hook for schemes such as ``gs``.
|
|
123
|
+
|
|
124
|
+
OSS provides the dispatch seam only; downstream packages own credentials and
|
|
125
|
+
scheme-specific SDK dependencies.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
URI_READERS[scheme] = reader
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def register_uri_writer(scheme: str, writer: UriWriter) -> None:
|
|
132
|
+
"""Register a native-free URI writer hook for schemes such as ``gs``."""
|
|
133
|
+
|
|
134
|
+
URI_WRITERS[scheme] = writer
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def available_uri_readers() -> list[str]:
|
|
138
|
+
"""Return registered URI reader schemes in stable display order."""
|
|
139
|
+
|
|
140
|
+
return sorted(URI_READERS)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def available_uri_writers() -> list[str]:
|
|
144
|
+
"""Return registered URI writer schemes in stable display order."""
|
|
145
|
+
|
|
146
|
+
return sorted(URI_WRITERS)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _available_resolvers_text(resolvers: Mapping[str, Any]) -> str:
|
|
150
|
+
return ", ".join(sorted(resolvers)) or "(none)"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_template(
|
|
154
|
+
template_path: Path,
|
|
155
|
+
context: dict[str, Any],
|
|
156
|
+
overrides: dict[str, Any] | None = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Render a Jinja2 template with the given context.
|
|
159
|
+
|
|
160
|
+
Ported from TD's ``render_template`` helper, including ``include_raw``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
from jinja2 import Environment, FileSystemLoader
|
|
164
|
+
|
|
165
|
+
template_dir = template_path.parent
|
|
166
|
+
template_name = template_path.name
|
|
167
|
+
env = Environment(
|
|
168
|
+
loader=FileSystemLoader(str(template_dir)),
|
|
169
|
+
keep_trailing_newline=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def include_raw(path: str) -> str:
|
|
173
|
+
"""Include a file's contents without Jinja2 processing."""
|
|
174
|
+
assert env.loader is not None
|
|
175
|
+
return env.loader.get_source(env, path)[0]
|
|
176
|
+
|
|
177
|
+
env.globals["include_raw"] = include_raw
|
|
178
|
+
template = env.get_template(template_name)
|
|
179
|
+
|
|
180
|
+
merged_context = dict(context)
|
|
181
|
+
if overrides:
|
|
182
|
+
merged_context.update(overrides)
|
|
183
|
+
|
|
184
|
+
return template.render(**merged_context)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PipelineHydrator(TangleCliHandler):
|
|
188
|
+
"""Hydrates pipeline YAML by resolving component references.
|
|
189
|
+
|
|
190
|
+
This class mirrors TD's ``PipelineHydrator`` shape. Supported generic refs:
|
|
191
|
+
``digest``, ``name``, ``url`` (``file://``, ``http(s)://``, ``resolve://``),
|
|
192
|
+
resolve-config ``local`` and ``local_from_python``. Unsupported TD refs:
|
|
193
|
+
GCS and Docker/from-container materialization.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
client: TangleApiClient | None = None,
|
|
199
|
+
upgrade_deprecated: bool = True,
|
|
200
|
+
verbose: bool = False,
|
|
201
|
+
enable_resolution: bool = True,
|
|
202
|
+
postprocess_task: Callable[[str, dict[str, Any], str], dict[str, Any]] | None = None,
|
|
203
|
+
logger: Logger | None = None,
|
|
204
|
+
resolution_overrides: dict[str, Any] | None = None,
|
|
205
|
+
*,
|
|
206
|
+
base_url: str | None = None,
|
|
207
|
+
token: str | None = None,
|
|
208
|
+
auth_header: str | None = None,
|
|
209
|
+
header: list[str] | None = None,
|
|
210
|
+
include_env_credentials: bool = True,
|
|
211
|
+
component_resolvers: Mapping[str, ComponentResolver] | None = None,
|
|
212
|
+
uri_readers: Mapping[str, UriReader] | None = None,
|
|
213
|
+
uri_writers: Mapping[str, UriWriter] | None = None,
|
|
214
|
+
component_generator: ComponentGenerator | None = None,
|
|
215
|
+
trusted_python_sources: list[str] | None = None,
|
|
216
|
+
allow_all_hydration: bool = False,
|
|
217
|
+
recursive_context: str | None = None,
|
|
218
|
+
error_policy: str = "warn",
|
|
219
|
+
) -> None:
|
|
220
|
+
super().__init__(client=client, logger=logger, base_url=base_url)
|
|
221
|
+
self._client_options = {
|
|
222
|
+
"base_url": base_url,
|
|
223
|
+
"token": token,
|
|
224
|
+
"auth_header": auth_header,
|
|
225
|
+
"header": header,
|
|
226
|
+
"include_env_credentials": include_env_credentials,
|
|
227
|
+
}
|
|
228
|
+
self.cache: dict[str, Any] = {}
|
|
229
|
+
self.upgrade_deprecated = upgrade_deprecated
|
|
230
|
+
self.verbose = verbose
|
|
231
|
+
self.enable_resolution = enable_resolution
|
|
232
|
+
self._postprocess_callback = postprocess_task
|
|
233
|
+
self.component_resolvers: dict[str, ComponentResolver] = dict(COMPONENT_RESOLVERS)
|
|
234
|
+
if component_resolvers:
|
|
235
|
+
self.component_resolvers.update(component_resolvers)
|
|
236
|
+
self.uri_readers: dict[str, UriReader] = dict(URI_READERS)
|
|
237
|
+
if uri_readers:
|
|
238
|
+
self.uri_readers.update(uri_readers)
|
|
239
|
+
self.uri_writers: dict[str, UriWriter] = dict(URI_WRITERS)
|
|
240
|
+
if uri_writers:
|
|
241
|
+
self.uri_writers.update(uri_writers)
|
|
242
|
+
self.component_generator = component_generator
|
|
243
|
+
self.resolution_overrides: dict[str, Any] = resolution_overrides or {}
|
|
244
|
+
self.trusted_python_sources = trusted_python_sources or []
|
|
245
|
+
self.allow_all_hydration = allow_all_hydration
|
|
246
|
+
self.recursive_context = self._recursive_context_value(recursive_context)
|
|
247
|
+
self._global_params: dict[str, Any] = {}
|
|
248
|
+
self.error_policy = error_policy
|
|
249
|
+
self._resolution_overrides_str: dict[str, str] = {
|
|
250
|
+
k: str(v) for k, v in self.resolution_overrides.items()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
def _create_client(self) -> TangleApiClient | None:
|
|
254
|
+
from . import client as client_module
|
|
255
|
+
|
|
256
|
+
return client_module.TangleApiClient(
|
|
257
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
258
|
+
**self._client_options,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _api_client(self) -> TangleApiClient:
|
|
262
|
+
client = self._get_client()
|
|
263
|
+
if client is None:
|
|
264
|
+
raise HydrationError("Failed to create TangleApiClient")
|
|
265
|
+
return client
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _recursive_context_value(value: Any) -> str | None:
|
|
269
|
+
if value is None:
|
|
270
|
+
return None
|
|
271
|
+
raw = getattr(value, "value", value)
|
|
272
|
+
normalized = str(raw).replace("_", "-").lower()
|
|
273
|
+
if normalized in {"parent-priority", "parent"}:
|
|
274
|
+
return "parent-priority"
|
|
275
|
+
if normalized in {"child-priority", "child"}:
|
|
276
|
+
return "child-priority"
|
|
277
|
+
raise ValueError(f"Unsupported recursive_context: {value!r}")
|
|
278
|
+
|
|
279
|
+
def _cache_key(
|
|
280
|
+
self,
|
|
281
|
+
ref_type: str,
|
|
282
|
+
ref_value: str,
|
|
283
|
+
base_dir: Path | None = None,
|
|
284
|
+
) -> str:
|
|
285
|
+
"""Compute a cache key for a component reference."""
|
|
286
|
+
key = f"{ref_type}:{ref_value}"
|
|
287
|
+
if self._ref_depends_on_base_dir(ref_type, ref_value):
|
|
288
|
+
resolved_base_dir = base_dir.resolve() if base_dir is not None else None
|
|
289
|
+
key = f"{key}:base={resolved_base_dir}"
|
|
290
|
+
if self.recursive_context and self._global_params:
|
|
291
|
+
params_hash = hash(json.dumps(self._global_params, sort_keys=True, default=str))
|
|
292
|
+
return f"{key}:ctx={params_hash}"
|
|
293
|
+
return key
|
|
294
|
+
|
|
295
|
+
def _ref_depends_on_base_dir(self, ref_type: str, ref_value: str) -> bool:
|
|
296
|
+
"""Return whether a ref resolves relative to the active base directory."""
|
|
297
|
+
if ref_type in {"local", "local_from_python"}:
|
|
298
|
+
return True
|
|
299
|
+
if ref_type != "url":
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
url = str(ref_value)
|
|
303
|
+
scheme = self._uri_scheme(url)
|
|
304
|
+
if scheme is None:
|
|
305
|
+
return not Path(url).is_absolute()
|
|
306
|
+
if scheme == "file":
|
|
307
|
+
return not Path(url[7:]).is_absolute()
|
|
308
|
+
if scheme == "resolve":
|
|
309
|
+
file_path = url[len("resolve://"):]
|
|
310
|
+
if "#" in file_path:
|
|
311
|
+
file_path, _fragment = file_path.rsplit("#", 1)
|
|
312
|
+
nested_scheme = self._uri_scheme(file_path)
|
|
313
|
+
if nested_scheme is None:
|
|
314
|
+
return not Path(file_path).is_absolute()
|
|
315
|
+
if nested_scheme == "file":
|
|
316
|
+
return not Path(file_path[7:]).is_absolute()
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
def _merge_with_global_params(self, child_params: dict[str, Any]) -> dict[str, Any]:
|
|
320
|
+
"""Merge child template params with inherited recursive-context params.
|
|
321
|
+
|
|
322
|
+
``parent-priority`` means inherited params win on conflicts;
|
|
323
|
+
``child-priority`` means the child template config wins.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
if not self.recursive_context or not self._global_params:
|
|
327
|
+
return dict(child_params)
|
|
328
|
+
if self.recursive_context == "parent-priority":
|
|
329
|
+
merged = dict(child_params)
|
|
330
|
+
merged.update(self._global_params)
|
|
331
|
+
return merged
|
|
332
|
+
merged = dict(self._global_params)
|
|
333
|
+
merged.update(child_params)
|
|
334
|
+
return merged
|
|
335
|
+
|
|
336
|
+
def _resolver_base_dirs(self, base_dir: Path | None) -> tuple[Path, ...]:
|
|
337
|
+
dirs = [path.resolve() for path in (base_dir, Path.cwd()) if path is not None]
|
|
338
|
+
seen: set[Path] = set()
|
|
339
|
+
result: list[Path] = []
|
|
340
|
+
for path in dirs:
|
|
341
|
+
if path not in seen:
|
|
342
|
+
seen.add(path)
|
|
343
|
+
result.append(path)
|
|
344
|
+
return tuple(result)
|
|
345
|
+
|
|
346
|
+
def _resolve_context_path(self, value: Any, base_dir: Path | None) -> Path | None:
|
|
347
|
+
if not value:
|
|
348
|
+
return None
|
|
349
|
+
path = Path(str(value))
|
|
350
|
+
if path.is_absolute():
|
|
351
|
+
return path.resolve()
|
|
352
|
+
return (base_dir / path).resolve() if base_dir is not None else path.resolve()
|
|
353
|
+
|
|
354
|
+
def make_resolver_context(
|
|
355
|
+
self,
|
|
356
|
+
kind: str,
|
|
357
|
+
value: Any,
|
|
358
|
+
path: str,
|
|
359
|
+
base_dir: Path | None,
|
|
360
|
+
) -> ResolverContext:
|
|
361
|
+
"""Build the structured context passed to downstream resolver hooks."""
|
|
362
|
+
|
|
363
|
+
source_path = None
|
|
364
|
+
output_folder = None
|
|
365
|
+
if isinstance(value, (str, Path)) and "://" not in str(value):
|
|
366
|
+
source_path = self._resolve_context_path(value, base_dir)
|
|
367
|
+
elif isinstance(value, dict):
|
|
368
|
+
source_path = self._resolve_context_path(
|
|
369
|
+
value.get("file") or value.get("source"), base_dir
|
|
370
|
+
)
|
|
371
|
+
output_folder = self._resolve_context_path(value.get("output_folder"), base_dir)
|
|
372
|
+
return ResolverContext(
|
|
373
|
+
kind=kind,
|
|
374
|
+
value=value,
|
|
375
|
+
path=path,
|
|
376
|
+
base_dir=base_dir,
|
|
377
|
+
base_dirs=self._resolver_base_dirs(base_dir),
|
|
378
|
+
source_path=source_path,
|
|
379
|
+
output_folder=output_folder,
|
|
380
|
+
verbose=self.verbose,
|
|
381
|
+
trusted_python_sources=tuple(self.trusted_python_sources),
|
|
382
|
+
allow_all_hydration=self.allow_all_hydration,
|
|
383
|
+
error_policy=self.error_policy,
|
|
384
|
+
resolution_overrides=self.resolution_overrides,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _accepts_resolver_context(resolver: ComponentResolver) -> bool:
|
|
389
|
+
try:
|
|
390
|
+
params = signature(resolver).parameters.values()
|
|
391
|
+
except (TypeError, ValueError):
|
|
392
|
+
return True
|
|
393
|
+
positional = [
|
|
394
|
+
p for p in params
|
|
395
|
+
if p.kind in {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD}
|
|
396
|
+
]
|
|
397
|
+
return any(p.kind == Parameter.VAR_POSITIONAL for p in params) or len(positional) >= 5
|
|
398
|
+
|
|
399
|
+
def _call_component_resolver(
|
|
400
|
+
self,
|
|
401
|
+
resolver: ComponentResolver,
|
|
402
|
+
value: Any,
|
|
403
|
+
path: str,
|
|
404
|
+
base_dir: Path | None,
|
|
405
|
+
context: ResolverContext,
|
|
406
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
407
|
+
if self._accepts_resolver_context(resolver):
|
|
408
|
+
return resolver(self, value, path, base_dir, context)
|
|
409
|
+
return resolver(self, value, path, base_dir)
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _uri_scheme(uri: str) -> str | None:
|
|
413
|
+
if "://" not in uri:
|
|
414
|
+
return None
|
|
415
|
+
return uri.split("://", 1)[0]
|
|
416
|
+
|
|
417
|
+
def _warn_or_raise_hydration_error(
|
|
418
|
+
self,
|
|
419
|
+
message: str,
|
|
420
|
+
exc: Exception | None = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
if isinstance(exc, UntrustedHydrationSourceError):
|
|
423
|
+
raise exc
|
|
424
|
+
if self.error_policy == "raise":
|
|
425
|
+
if isinstance(exc, HydrationError):
|
|
426
|
+
raise exc
|
|
427
|
+
raise HydrationError(message) from exc
|
|
428
|
+
self.log.warn(f" ⚠️ {message}")
|
|
429
|
+
|
|
430
|
+
def _read_uri_text(
|
|
431
|
+
self,
|
|
432
|
+
uri: str,
|
|
433
|
+
kind: str,
|
|
434
|
+
context: ResolverContext | None = None,
|
|
435
|
+
) -> str | None:
|
|
436
|
+
scheme = self._uri_scheme(uri)
|
|
437
|
+
if not scheme or scheme == "file":
|
|
438
|
+
path = Path(uri[7:] if uri.startswith("file://") else uri)
|
|
439
|
+
return path.read_text(encoding="utf-8")
|
|
440
|
+
reader = self.uri_readers.get(scheme)
|
|
441
|
+
if reader is None:
|
|
442
|
+
raise UnsupportedHydrationFeatureError(
|
|
443
|
+
f"Unsupported {kind} URI scheme {scheme!r}. Registered URI readers: "
|
|
444
|
+
f"{_available_resolvers_text(self.uri_readers)}"
|
|
445
|
+
)
|
|
446
|
+
hook_context = context or self.make_resolver_context(scheme, uri, kind, None)
|
|
447
|
+
try:
|
|
448
|
+
return reader(self, uri, hook_context)
|
|
449
|
+
except FileNotFoundError as exc:
|
|
450
|
+
message = f"{kind.capitalize()} not found at URI {uri}"
|
|
451
|
+
if kind == "pipeline":
|
|
452
|
+
raise HydrationError(message) from exc
|
|
453
|
+
self._warn_or_raise_hydration_error(message, exc)
|
|
454
|
+
return None
|
|
455
|
+
except Exception as exc:
|
|
456
|
+
message = f"Error reading {kind} URI {uri}: {exc}"
|
|
457
|
+
if kind == "pipeline":
|
|
458
|
+
raise HydrationError(message) from exc
|
|
459
|
+
self._warn_or_raise_hydration_error(message, exc)
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
def _write_uri_text(
|
|
463
|
+
self,
|
|
464
|
+
uri: str,
|
|
465
|
+
content: str,
|
|
466
|
+
context: ResolverContext | None = None,
|
|
467
|
+
) -> None:
|
|
468
|
+
scheme = self._uri_scheme(uri)
|
|
469
|
+
if not scheme or scheme == "file":
|
|
470
|
+
path = Path(uri[7:] if uri.startswith("file://") else uri)
|
|
471
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
path.write_text(content, encoding="utf-8")
|
|
473
|
+
return
|
|
474
|
+
writer = self.uri_writers.get(scheme)
|
|
475
|
+
if writer is None:
|
|
476
|
+
raise UnsupportedHydrationFeatureError(
|
|
477
|
+
f"Unsupported output URI scheme {scheme!r}. Registered URI writers: "
|
|
478
|
+
f"{_available_resolvers_text(self.uri_writers)}"
|
|
479
|
+
)
|
|
480
|
+
hook_context = context or self.make_resolver_context(scheme, uri, "output", None)
|
|
481
|
+
writer(self, uri, content, hook_context)
|
|
482
|
+
|
|
483
|
+
def available_component_resolvers(self) -> list[str]:
|
|
484
|
+
"""Return resolver kinds available on this hydrator instance."""
|
|
485
|
+
|
|
486
|
+
return sorted(self.component_resolvers)
|
|
487
|
+
|
|
488
|
+
def register_component_resolver(self, kind: str, resolver: ComponentResolver) -> None:
|
|
489
|
+
"""Register or replace a resolver on this hydrator instance."""
|
|
490
|
+
|
|
491
|
+
self.component_resolvers[kind] = resolver
|
|
492
|
+
|
|
493
|
+
def _unsupported_resolver(self, kind: str) -> UnsupportedHydrationFeatureError:
|
|
494
|
+
return UnsupportedHydrationFeatureError(
|
|
495
|
+
f"Unsupported component resolver {kind!r}. Available resolvers: "
|
|
496
|
+
f"{_available_resolvers_text(self.component_resolvers)}"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def _resolve_registered_component(
|
|
500
|
+
self,
|
|
501
|
+
kind: str,
|
|
502
|
+
value: Any,
|
|
503
|
+
path: str,
|
|
504
|
+
base_dir: Path | None,
|
|
505
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
506
|
+
resolver = self.component_resolvers.get(kind)
|
|
507
|
+
if resolver is None:
|
|
508
|
+
raise self._unsupported_resolver(kind)
|
|
509
|
+
context = self.make_resolver_context(kind, value, path, base_dir)
|
|
510
|
+
result = self._call_component_resolver(resolver, value, path, base_dir, context)
|
|
511
|
+
if result is None:
|
|
512
|
+
return None
|
|
513
|
+
digest, spec = result
|
|
514
|
+
return digest, self.normalize_component_spec(spec)
|
|
515
|
+
|
|
516
|
+
def normalize_component_spec(self, component: Any) -> dict[str, Any]:
|
|
517
|
+
"""Return a mutable YAML-shaped copy of a component spec.
|
|
518
|
+
|
|
519
|
+
Core resolver code operates on component specs as dictionaries. API
|
|
520
|
+
clients and downstream packages may return generated model instances
|
|
521
|
+
instead; this hook is the normalization seam that lets them keep their
|
|
522
|
+
client-specific return types without overriding fetch/name/latest/digest
|
|
523
|
+
resolver logic.
|
|
524
|
+
"""
|
|
525
|
+
if isinstance(component, Mapping):
|
|
526
|
+
return copy.deepcopy(dict(component))
|
|
527
|
+
|
|
528
|
+
to_mutable_spec_dict = getattr(component, "to_mutable_spec_dict", None)
|
|
529
|
+
if callable(to_mutable_spec_dict):
|
|
530
|
+
spec = to_mutable_spec_dict()
|
|
531
|
+
if isinstance(spec, Mapping):
|
|
532
|
+
return copy.deepcopy(dict(spec))
|
|
533
|
+
|
|
534
|
+
data = getattr(component, "data", None)
|
|
535
|
+
if isinstance(data, Mapping):
|
|
536
|
+
return copy.deepcopy(dict(data))
|
|
537
|
+
|
|
538
|
+
raise HydrationError(
|
|
539
|
+
"Component spec must be a mapping or expose mapping-like data; "
|
|
540
|
+
f"got {type(component).__name__}"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def fetch_component(self, digest: str) -> tuple[str, dict[str, Any]]:
|
|
544
|
+
"""Fetch a component, optionally following deprecation successors."""
|
|
545
|
+
client = self._api_client()
|
|
546
|
+
current_digest = client.resolve_digest(digest) if self.upgrade_deprecated else digest
|
|
547
|
+
component = client.get_component_spec(current_digest)
|
|
548
|
+
spec = self.normalize_component_spec(component)
|
|
549
|
+
if self.verbose:
|
|
550
|
+
self.log.info(f" [verbose] get_component_spec({current_digest}):")
|
|
551
|
+
self.log.info(json.dumps(spec, indent=2, default=str))
|
|
552
|
+
return current_digest, spec
|
|
553
|
+
|
|
554
|
+
def _fetch_component_by_digest(
|
|
555
|
+
self,
|
|
556
|
+
digest: str,
|
|
557
|
+
path: str,
|
|
558
|
+
base_dir: Path | None = None,
|
|
559
|
+
) -> tuple[str, dict[str, Any]]:
|
|
560
|
+
"""Fetch a component by digest and return as dict."""
|
|
561
|
+
self.log.info(f" Fetching component: {digest[:16]}... ({path})")
|
|
562
|
+
resolved_digest, component = self.fetch_component(digest)
|
|
563
|
+
return resolved_digest, self.normalize_component_spec(component)
|
|
564
|
+
|
|
565
|
+
def _find_latest_version_component(
|
|
566
|
+
self,
|
|
567
|
+
components: list[ComponentInfo],
|
|
568
|
+
) -> tuple[str, dict[str, Any]]:
|
|
569
|
+
"""Find the component with the highest version from a list."""
|
|
570
|
+
client = self._api_client()
|
|
571
|
+
|
|
572
|
+
def _fetch(digest: str) -> tuple[str, dict[str, Any]]:
|
|
573
|
+
component = client.get_component_spec(digest)
|
|
574
|
+
if not component:
|
|
575
|
+
raise HydrationError(f"Component not found: {digest}")
|
|
576
|
+
return digest, self.normalize_component_spec(component)
|
|
577
|
+
|
|
578
|
+
digests = [c.digest for c in components if c.digest]
|
|
579
|
+
if not digests:
|
|
580
|
+
raise HydrationError("No components with a digest found")
|
|
581
|
+
if len(digests) == 1:
|
|
582
|
+
return _fetch(digests[0])
|
|
583
|
+
|
|
584
|
+
versioned_components: list[tuple[str, dict[str, Any], str]] = []
|
|
585
|
+
for digest in digests:
|
|
586
|
+
try:
|
|
587
|
+
_, spec = _fetch(digest)
|
|
588
|
+
version = utils.get_version_from_data(spec)
|
|
589
|
+
if version:
|
|
590
|
+
versioned_components.append((digest, spec, version))
|
|
591
|
+
except Exception as exc:
|
|
592
|
+
self.log.warn(f" ⚠️ Failed to fetch component {digest[:16]}...: {exc}")
|
|
593
|
+
|
|
594
|
+
if not versioned_components:
|
|
595
|
+
return _fetch(digests[0])
|
|
596
|
+
|
|
597
|
+
best_digest, best_spec, best_version = versioned_components[0]
|
|
598
|
+
for digest, spec, version in versioned_components[1:]:
|
|
599
|
+
if utils.compare_versions(version, best_version) > 0:
|
|
600
|
+
best_digest, best_spec, best_version = digest, spec, version
|
|
601
|
+
return best_digest, best_spec
|
|
602
|
+
|
|
603
|
+
def _fetch_component_by_name(
|
|
604
|
+
self,
|
|
605
|
+
component_name: str,
|
|
606
|
+
path: str,
|
|
607
|
+
base_dir: Path | None = None,
|
|
608
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
609
|
+
"""Fetch a component by name and return as dict."""
|
|
610
|
+
self.log.info(f" Finding component by name: {component_name}... ({path})")
|
|
611
|
+
search_names = [component_name, add_official_prefix(component_name)]
|
|
612
|
+
existing = self._api_client().find_existing_components(search_names, verbose=False)
|
|
613
|
+
if not existing:
|
|
614
|
+
self.log.warn(f" ⚠️ No component found with name: {component_name}")
|
|
615
|
+
return None
|
|
616
|
+
found_digest, spec = self._find_latest_version_component(existing)
|
|
617
|
+
self.log.info(f" Found digest: {found_digest[:16]}...")
|
|
618
|
+
return found_digest, spec
|
|
619
|
+
|
|
620
|
+
def _fetch_component_by_url(
|
|
621
|
+
self,
|
|
622
|
+
url: str,
|
|
623
|
+
path: str,
|
|
624
|
+
base_dir: Path | None = None,
|
|
625
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
626
|
+
"""Fetch a component by URL and return as dict."""
|
|
627
|
+
scheme = self._uri_scheme(url)
|
|
628
|
+
if scheme == "resolve":
|
|
629
|
+
result = self._fetch_component_by_resolve_url(url, path, base_dir)
|
|
630
|
+
else:
|
|
631
|
+
try:
|
|
632
|
+
if scheme is None:
|
|
633
|
+
if base_dir is None and not Path(url).is_absolute():
|
|
634
|
+
raise HydrationError(
|
|
635
|
+
f"Scheme-less component URL {url!r} requires a local base directory"
|
|
636
|
+
)
|
|
637
|
+
result = self._fetch_component_from_file_url(f"file://{url}", path, base_dir)
|
|
638
|
+
elif scheme in {"http", "https"}:
|
|
639
|
+
# Preserve HTTP(S) component URL behavior through the
|
|
640
|
+
# overridable component resolver. HTTP(S) URI readers are
|
|
641
|
+
# registered for non-component URI contexts such as
|
|
642
|
+
# ``resolve://https://...`` configs.
|
|
643
|
+
result = self._resolve_registered_component(scheme, url, path, base_dir)
|
|
644
|
+
elif scheme in self.uri_readers:
|
|
645
|
+
result = self._fetch_component_from_uri(url, path, base_dir)
|
|
646
|
+
else:
|
|
647
|
+
result = self._resolve_registered_component(scheme, url, path, base_dir)
|
|
648
|
+
except HydrationError as exc:
|
|
649
|
+
if isinstance(exc, UntrustedHydrationSourceError):
|
|
650
|
+
raise
|
|
651
|
+
self._warn_or_raise_hydration_error(str(exc), exc)
|
|
652
|
+
return None
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
self._warn_or_raise_hydration_error(
|
|
655
|
+
f"Failed to fetch component from URL {url}: {exc}", exc
|
|
656
|
+
)
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
if self.verbose and result is not None:
|
|
660
|
+
_, spec = result
|
|
661
|
+
self.log.info(" [verbose] Component spec from URL:")
|
|
662
|
+
self.log.info(json.dumps(spec, indent=2, default=str))
|
|
663
|
+
return result
|
|
664
|
+
|
|
665
|
+
def fetch_remote_component(
|
|
666
|
+
self,
|
|
667
|
+
url: str,
|
|
668
|
+
path: str,
|
|
669
|
+
base_dir: Path | None = None,
|
|
670
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
671
|
+
"""Fetch a remote HTTP(S) component.
|
|
672
|
+
|
|
673
|
+
Kept as an overridable hook for downstream packages that need custom
|
|
674
|
+
transport, auth, mirrors, or auditing.
|
|
675
|
+
"""
|
|
676
|
+
self.log.info(f" Downloading component from URL: {url}... ({path})")
|
|
677
|
+
try:
|
|
678
|
+
with urllib.request.urlopen(url, timeout=30) as response:
|
|
679
|
+
yaml_text = response.read().decode("utf-8")
|
|
680
|
+
spec = yaml.safe_load(yaml_text)
|
|
681
|
+
except urllib.error.URLError as exc:
|
|
682
|
+
raise HydrationError(f"Failed to download YAML from {url}: {exc}") from exc
|
|
683
|
+
except yaml.YAMLError as exc:
|
|
684
|
+
raise HydrationError(f"Failed to parse downloaded YAML from {url}: {exc}") from exc
|
|
685
|
+
|
|
686
|
+
if not isinstance(spec, dict):
|
|
687
|
+
raise HydrationError(f"Component YAML at {url} must be a mapping")
|
|
688
|
+
digest = utils.compute_text_digest(yaml_text)
|
|
689
|
+
self.log.info(
|
|
690
|
+
f" ✅ Downloaded component: {spec.get('name', 'unknown')} "
|
|
691
|
+
f"(digest: {digest[:16]}...)"
|
|
692
|
+
)
|
|
693
|
+
return digest, spec
|
|
694
|
+
|
|
695
|
+
def _fetch_component_from_uri(
|
|
696
|
+
self,
|
|
697
|
+
url: str,
|
|
698
|
+
path: str,
|
|
699
|
+
base_dir: Path | None = None,
|
|
700
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
701
|
+
"""Fetch component YAML through a registered URI reader hook."""
|
|
702
|
+
|
|
703
|
+
context = self.make_resolver_context(self._uri_scheme(url) or "url", url, path, base_dir)
|
|
704
|
+
yaml_text = self._read_uri_text(url, "component", context)
|
|
705
|
+
if yaml_text is None:
|
|
706
|
+
return None
|
|
707
|
+
try:
|
|
708
|
+
spec = yaml.safe_load(yaml_text)
|
|
709
|
+
except yaml.YAMLError as exc:
|
|
710
|
+
self._warn_or_raise_hydration_error(
|
|
711
|
+
f"Failed to parse component YAML from {url}: {exc}", exc
|
|
712
|
+
)
|
|
713
|
+
return None
|
|
714
|
+
if spec is None:
|
|
715
|
+
self._warn_or_raise_hydration_error(f"Failed to parse YAML from {url}")
|
|
716
|
+
return None
|
|
717
|
+
if not isinstance(spec, dict):
|
|
718
|
+
self._warn_or_raise_hydration_error(
|
|
719
|
+
f"Component YAML at {url} is a {type(spec).__name__}, expected a mapping"
|
|
720
|
+
)
|
|
721
|
+
return None
|
|
722
|
+
if "template_file" in spec:
|
|
723
|
+
self._warn_or_raise_hydration_error(
|
|
724
|
+
"Component at non-local URI is a template_file config; "
|
|
725
|
+
"render it locally and publish the rendered result instead"
|
|
726
|
+
)
|
|
727
|
+
return None
|
|
728
|
+
digest = utils.compute_text_digest(yaml_text)
|
|
729
|
+
self.log.info(
|
|
730
|
+
f" ✅ Loaded component: {spec.get('name', 'unknown')} "
|
|
731
|
+
f"(digest: {digest[:16]}...)"
|
|
732
|
+
)
|
|
733
|
+
return digest, spec
|
|
734
|
+
|
|
735
|
+
def load_gcs_uri(
|
|
736
|
+
self,
|
|
737
|
+
url: str,
|
|
738
|
+
path: str,
|
|
739
|
+
base_dir: Path | None = None,
|
|
740
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
741
|
+
"""Compatibility hook for downstream GCS support via URI readers."""
|
|
742
|
+
|
|
743
|
+
return self._fetch_component_from_uri(url, path, base_dir)
|
|
744
|
+
|
|
745
|
+
def _render_template_config(
|
|
746
|
+
self,
|
|
747
|
+
file_path: Path,
|
|
748
|
+
config: dict[str, Any],
|
|
749
|
+
overrides: dict[str, Any] | None = None,
|
|
750
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
751
|
+
"""If config contains template_file, render the Jinja2 template."""
|
|
752
|
+
if "template_file" not in config:
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
template_path = config["template_file"]
|
|
756
|
+
full_template_path = (file_path.parent / template_path).resolve()
|
|
757
|
+
if not full_template_path.exists():
|
|
758
|
+
self.log.warn(f" ⚠️ Template file not found: {full_template_path}")
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
context = {k: v for k, v in config.items() if k != "template_file"}
|
|
762
|
+
if overrides:
|
|
763
|
+
context.update(overrides)
|
|
764
|
+
self.log.info(f" 🔧 Rendering template: {template_path}")
|
|
765
|
+
rendered = render_template(full_template_path, context)
|
|
766
|
+
spec = yaml.safe_load(rendered)
|
|
767
|
+
if not isinstance(spec, dict):
|
|
768
|
+
self.log.warn(f" ⚠️ Rendered template produced invalid YAML: {full_template_path}")
|
|
769
|
+
return None
|
|
770
|
+
return rendered, spec
|
|
771
|
+
|
|
772
|
+
def postprocess_loaded_local_spec(
|
|
773
|
+
self,
|
|
774
|
+
spec: dict[str, Any],
|
|
775
|
+
*,
|
|
776
|
+
file_path: Path,
|
|
777
|
+
yaml_text: str,
|
|
778
|
+
rendered_from_template: bool,
|
|
779
|
+
) -> dict[str, Any]:
|
|
780
|
+
"""Hook for downstream metadata on locally loaded component specs.
|
|
781
|
+
|
|
782
|
+
Called after local file/template loading and ``_source_dir`` provenance
|
|
783
|
+
are applied, before digest calculation and nested ref resolution. The
|
|
784
|
+
default implementation is native-free and leaves the spec unchanged.
|
|
785
|
+
"""
|
|
786
|
+
del file_path, yaml_text, rendered_from_template
|
|
787
|
+
return spec
|
|
788
|
+
|
|
789
|
+
def _fetch_component_from_file_url(
|
|
790
|
+
self,
|
|
791
|
+
url: str,
|
|
792
|
+
display_path: str,
|
|
793
|
+
base_dir: Path | None = None,
|
|
794
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
795
|
+
"""Fetch a component from a file:// URL.
|
|
796
|
+
|
|
797
|
+
Supports absolute and relative ``file://`` URLs and template configs,
|
|
798
|
+
matching TD's generic local-file behavior.
|
|
799
|
+
"""
|
|
800
|
+
file_path = url[7:]
|
|
801
|
+
self.log.info(f" Loading component from file URL: {url}... ({display_path})")
|
|
802
|
+
path_obj = Path(file_path)
|
|
803
|
+
if not path_obj.is_absolute() and base_dir:
|
|
804
|
+
path_obj = (base_dir / path_obj).resolve()
|
|
805
|
+
else:
|
|
806
|
+
path_obj = path_obj.resolve()
|
|
807
|
+
if not path_obj.exists():
|
|
808
|
+
self.log.warn(f" ⚠️ Component file not found: {path_obj}")
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
yaml_text = path_obj.read_text(encoding="utf-8")
|
|
813
|
+
spec = yaml.safe_load(yaml_text)
|
|
814
|
+
except Exception as exc:
|
|
815
|
+
raise HydrationError(f"Error reading component file {path_obj}: {exc}") from exc
|
|
816
|
+
if not isinstance(spec, dict):
|
|
817
|
+
raise HydrationError(f"Component file {path_obj} must contain a mapping")
|
|
818
|
+
|
|
819
|
+
rendered_from_template = False
|
|
820
|
+
if "template_file" in spec:
|
|
821
|
+
merged_params: dict[str, Any] | None = None
|
|
822
|
+
if self.recursive_context and self._global_params:
|
|
823
|
+
child_params = {k: v for k, v in spec.items() if k != "template_file"}
|
|
824
|
+
merged_params = self._merge_with_global_params(child_params)
|
|
825
|
+
spec = {"template_file": spec["template_file"], **merged_params}
|
|
826
|
+
result = self._render_template_config(path_obj, spec)
|
|
827
|
+
if result is None:
|
|
828
|
+
return None
|
|
829
|
+
yaml_text, spec = result
|
|
830
|
+
rendered_from_template = True
|
|
831
|
+
if merged_params is not None:
|
|
832
|
+
spec["_recursive_params"] = merged_params
|
|
833
|
+
|
|
834
|
+
# Match TD provenance behavior: nested refs inside a loaded component
|
|
835
|
+
# resolve relative to the component file that contains them, not the
|
|
836
|
+
# original top-level pipeline file.
|
|
837
|
+
spec["_source_dir"] = str(path_obj.parent)
|
|
838
|
+
try:
|
|
839
|
+
spec = self.postprocess_loaded_local_spec(
|
|
840
|
+
spec,
|
|
841
|
+
file_path=path_obj,
|
|
842
|
+
yaml_text=yaml_text,
|
|
843
|
+
rendered_from_template=rendered_from_template,
|
|
844
|
+
)
|
|
845
|
+
except Exception as exc:
|
|
846
|
+
raise HydrationError(
|
|
847
|
+
f"Error postprocessing component file {path_obj}: {exc}"
|
|
848
|
+
) from exc
|
|
849
|
+
|
|
850
|
+
digest = utils.compute_text_digest(yaml_text)
|
|
851
|
+
self.log.info(
|
|
852
|
+
f" ✅ Loaded component: {spec.get('name', 'unknown')} "
|
|
853
|
+
f"(digest: {digest[:16]}...)"
|
|
854
|
+
)
|
|
855
|
+
return digest, spec
|
|
856
|
+
|
|
857
|
+
def _fetch_component_by_resolve_url(
|
|
858
|
+
self,
|
|
859
|
+
url: str,
|
|
860
|
+
path: str,
|
|
861
|
+
base_dir: Path | None = None,
|
|
862
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
863
|
+
"""Fetch a component using a resolve:// URL pointing to a config."""
|
|
864
|
+
file_path = url[len("resolve://"):]
|
|
865
|
+
fragment: str | None = None
|
|
866
|
+
if "#" in file_path:
|
|
867
|
+
file_path, fragment = file_path.rsplit("#", 1)
|
|
868
|
+
|
|
869
|
+
self.log.info(f" Resolving component via config: {url}... ({path})")
|
|
870
|
+
|
|
871
|
+
scheme = self._uri_scheme(file_path)
|
|
872
|
+
source: str
|
|
873
|
+
if scheme and scheme != "file":
|
|
874
|
+
source = file_path
|
|
875
|
+
nested_base_dir = None
|
|
876
|
+
text = self._read_uri_text(
|
|
877
|
+
file_path,
|
|
878
|
+
"resolve config",
|
|
879
|
+
self.make_resolver_context(scheme, file_path, path, base_dir),
|
|
880
|
+
)
|
|
881
|
+
if text is None:
|
|
882
|
+
return None
|
|
883
|
+
else:
|
|
884
|
+
raw_path = file_path[7:] if file_path.startswith("file://") else file_path
|
|
885
|
+
path_obj = Path(raw_path)
|
|
886
|
+
if not path_obj.is_absolute() and base_dir:
|
|
887
|
+
path_obj = (base_dir / path_obj).resolve()
|
|
888
|
+
else:
|
|
889
|
+
path_obj = path_obj.resolve()
|
|
890
|
+
if not path_obj.exists():
|
|
891
|
+
self.log.warn(f" ⚠️ Resolve config not found: {path_obj}")
|
|
892
|
+
return None
|
|
893
|
+
source = str(path_obj)
|
|
894
|
+
nested_base_dir = path_obj.parent
|
|
895
|
+
try:
|
|
896
|
+
text = path_obj.read_text(encoding="utf-8")
|
|
897
|
+
except Exception as exc:
|
|
898
|
+
self._warn_or_raise_hydration_error(
|
|
899
|
+
f"Error reading resolve config {path_obj}: {exc}", exc
|
|
900
|
+
)
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
try:
|
|
904
|
+
text = utils.expand_vars(text, self._resolution_overrides_str)
|
|
905
|
+
config = yaml.safe_load(text)
|
|
906
|
+
except utils.UnsetVarError as exc:
|
|
907
|
+
self.log.warn(f" ⚠️ Resolve config {source}: unset variable {exc}")
|
|
908
|
+
return None
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
self._warn_or_raise_hydration_error(
|
|
911
|
+
f"Error parsing resolve config {source}: {exc}", exc
|
|
912
|
+
)
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
if fragment is not None:
|
|
916
|
+
if not isinstance(config, dict) or fragment not in config:
|
|
917
|
+
self.log.warn(
|
|
918
|
+
f" ⚠️ Fragment '{fragment}' not found in resolve config {source}"
|
|
919
|
+
)
|
|
920
|
+
return None
|
|
921
|
+
entry = config[fragment]
|
|
922
|
+
defaults = config.get("_defaults")
|
|
923
|
+
if isinstance(defaults, dict) and isinstance(entry, (dict, list)):
|
|
924
|
+
config = utils.apply_defaults(entry, defaults)
|
|
925
|
+
else:
|
|
926
|
+
config = entry
|
|
927
|
+
|
|
928
|
+
return self._resolve_from_config(config, path, nested_base_dir)
|
|
929
|
+
|
|
930
|
+
def _resolve_from_config(
|
|
931
|
+
self,
|
|
932
|
+
config: dict[str, Any] | list[dict[str, Any]],
|
|
933
|
+
path: str,
|
|
934
|
+
base_dir: Path | None = None,
|
|
935
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
936
|
+
"""Resolve a component from a parsed resolve config."""
|
|
937
|
+
entries = config if isinstance(config, list) else [config]
|
|
938
|
+
for i, entry in enumerate(entries):
|
|
939
|
+
if not isinstance(entry, dict):
|
|
940
|
+
self.log.warn(f" ⚠️ Resolve config entry {i} is not a dict, skipping")
|
|
941
|
+
continue
|
|
942
|
+
result = self._try_resolve_entry(entry, path, base_dir)
|
|
943
|
+
if result is not None:
|
|
944
|
+
return result
|
|
945
|
+
self.log.warn(f" ⚠️ No resolve config entry matched at {path}")
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
def _try_resolve_entry(
|
|
949
|
+
self,
|
|
950
|
+
entry: dict[str, Any],
|
|
951
|
+
path: str,
|
|
952
|
+
base_dir: Path | None = None,
|
|
953
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
954
|
+
"""Try to resolve a single resolve-config entry.
|
|
955
|
+
|
|
956
|
+
Generic resolution lives here: resolve the primary source, choose one
|
|
957
|
+
local-side resolver by registry order, optionally use a cheap version
|
|
958
|
+
preview to skip materialization, then compare versions when both sides
|
|
959
|
+
resolve. Downstreams add new local-side behavior by registering a
|
|
960
|
+
resolver and (optionally) overriding ``preview_resolver_version``.
|
|
961
|
+
"""
|
|
962
|
+
primary = self._resolve_primary(entry, path, base_dir)
|
|
963
|
+
local_kind, local_value = self._select_local_entry(entry)
|
|
964
|
+
|
|
965
|
+
if primary and local_kind and local_value:
|
|
966
|
+
preview_winner = self._preview_decide_winner(
|
|
967
|
+
local_kind, local_value, primary, base_dir
|
|
968
|
+
)
|
|
969
|
+
if preview_winner == "primary":
|
|
970
|
+
return primary
|
|
971
|
+
else:
|
|
972
|
+
preview_winner = None
|
|
973
|
+
|
|
974
|
+
local_result = None
|
|
975
|
+
if local_kind and local_value:
|
|
976
|
+
local_result = self._resolve_registered_component(
|
|
977
|
+
local_kind, local_value, path, base_dir
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
if not primary and not local_result:
|
|
981
|
+
return None
|
|
982
|
+
if not primary:
|
|
983
|
+
self.log.info(
|
|
984
|
+
f" Resolve: primary source failed, using {local_kind or 'local source'}"
|
|
985
|
+
)
|
|
986
|
+
return local_result
|
|
987
|
+
if not local_result:
|
|
988
|
+
return primary
|
|
989
|
+
if preview_winner == "local":
|
|
990
|
+
return local_result
|
|
991
|
+
return self._pick_higher_version(primary, local_result, path)
|
|
992
|
+
|
|
993
|
+
def _resolve_primary(
|
|
994
|
+
self,
|
|
995
|
+
entry: dict[str, Any],
|
|
996
|
+
path: str,
|
|
997
|
+
base_dir: Path | None = None,
|
|
998
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
999
|
+
"""Resolve the primary source from a resolve-config entry."""
|
|
1000
|
+
for kind in ("digest", "url"):
|
|
1001
|
+
if kind not in entry:
|
|
1002
|
+
continue
|
|
1003
|
+
value = entry[kind]
|
|
1004
|
+
if kind == "digest":
|
|
1005
|
+
self.log.info(f" Resolve: trying digest={str(value)[:16]}...")
|
|
1006
|
+
elif kind == "url":
|
|
1007
|
+
self.log.info(f" Resolve: trying url={value}")
|
|
1008
|
+
return self._resolve_registered_component(kind, value, path, base_dir)
|
|
1009
|
+
if "name" in entry:
|
|
1010
|
+
return self._resolve_by_name_with_filters(entry)
|
|
1011
|
+
if any(kind in entry for kind in self._resolve_entry_kinds()):
|
|
1012
|
+
return None
|
|
1013
|
+
self.log.warn(
|
|
1014
|
+
" ⚠️ Resolve config entry has no registered resolver key. "
|
|
1015
|
+
f"Available resolvers: {_available_resolvers_text(self.component_resolvers)}"
|
|
1016
|
+
)
|
|
1017
|
+
return None
|
|
1018
|
+
|
|
1019
|
+
def _select_local_entry(self, entry: dict[str, Any]) -> tuple[str | None, Any]:
|
|
1020
|
+
"""Return the local-side resolver selected for a resolve-config entry.
|
|
1021
|
+
|
|
1022
|
+
Registry order defines priority. If multiple local resolver fields are
|
|
1023
|
+
present, use the first and warn about the ignored ones. This keeps
|
|
1024
|
+
precedence generic so downstream-only fields (for example TD's
|
|
1025
|
+
``local_from_docker``) don't require overriding the whole resolve flow.
|
|
1026
|
+
"""
|
|
1027
|
+
|
|
1028
|
+
used = [
|
|
1029
|
+
(kind, entry[kind])
|
|
1030
|
+
for kind in self._resolve_entry_kinds()
|
|
1031
|
+
if kind in entry and kind not in {"digest", "url", "name"} and entry[kind]
|
|
1032
|
+
]
|
|
1033
|
+
if not used:
|
|
1034
|
+
return None, None
|
|
1035
|
+
if len(used) > 1:
|
|
1036
|
+
kept, ignored = used[0], [kind for kind, _ in used[1:]]
|
|
1037
|
+
self.log.warn(
|
|
1038
|
+
f" ⚠️ Resolve entry has multiple local sources "
|
|
1039
|
+
f"{[kind for kind, _ in used]}; using '{kept[0]}' and ignoring {ignored}"
|
|
1040
|
+
)
|
|
1041
|
+
return used[0]
|
|
1042
|
+
|
|
1043
|
+
def _resolve_local_side(
|
|
1044
|
+
self,
|
|
1045
|
+
entry: dict[str, Any],
|
|
1046
|
+
path: str,
|
|
1047
|
+
base_dir: Path | None = None,
|
|
1048
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1049
|
+
kind, value = self._select_local_entry(entry)
|
|
1050
|
+
if kind and value:
|
|
1051
|
+
return self._resolve_registered_component(kind, value, path, base_dir)
|
|
1052
|
+
return None
|
|
1053
|
+
|
|
1054
|
+
def _resolve_entry_kinds(self) -> tuple[str, ...]:
|
|
1055
|
+
builtin_kinds = (
|
|
1056
|
+
"digest",
|
|
1057
|
+
"url",
|
|
1058
|
+
"name",
|
|
1059
|
+
"local",
|
|
1060
|
+
"local_from_python",
|
|
1061
|
+
"local_from_docker",
|
|
1062
|
+
"local_from_container",
|
|
1063
|
+
"from_docker",
|
|
1064
|
+
"from_container",
|
|
1065
|
+
"from-docker",
|
|
1066
|
+
"from-container",
|
|
1067
|
+
)
|
|
1068
|
+
return tuple(dict.fromkeys((*builtin_kinds, *self.component_resolvers)))
|
|
1069
|
+
|
|
1070
|
+
def preview_resolver_version(
|
|
1071
|
+
self,
|
|
1072
|
+
kind: str,
|
|
1073
|
+
value: Any,
|
|
1074
|
+
base_dir: Path | None = None,
|
|
1075
|
+
) -> str | None:
|
|
1076
|
+
"""Cheaply read a local resolver's version without materializing it.
|
|
1077
|
+
|
|
1078
|
+
The base OSS implementation supports ``local_from_python`` by reading
|
|
1079
|
+
docstring metadata via AST. Downstreams can override this for their own
|
|
1080
|
+
local resolvers while retaining the generic resolve/compare flow.
|
|
1081
|
+
"""
|
|
1082
|
+
|
|
1083
|
+
if kind != "local_from_python" or not isinstance(value, dict):
|
|
1084
|
+
return None
|
|
1085
|
+
file_field = value.get("file")
|
|
1086
|
+
if not file_field:
|
|
1087
|
+
return None
|
|
1088
|
+
py_path = Path(file_field)
|
|
1089
|
+
if not py_path.is_absolute() and base_dir is not None:
|
|
1090
|
+
py_path = (base_dir / py_path).resolve()
|
|
1091
|
+
else:
|
|
1092
|
+
py_path = py_path.resolve()
|
|
1093
|
+
if not py_path.exists():
|
|
1094
|
+
return None
|
|
1095
|
+
try:
|
|
1096
|
+
from .component_from_func import extract_file_metadata
|
|
1097
|
+
|
|
1098
|
+
metadata, resolved_func = extract_file_metadata(py_path, value.get("function"))
|
|
1099
|
+
except Exception:
|
|
1100
|
+
return None
|
|
1101
|
+
if not resolved_func:
|
|
1102
|
+
return None
|
|
1103
|
+
version = metadata.get("version")
|
|
1104
|
+
return str(version) if version else None
|
|
1105
|
+
|
|
1106
|
+
def _preview_decide_winner(
|
|
1107
|
+
self,
|
|
1108
|
+
kind: str,
|
|
1109
|
+
value: Any,
|
|
1110
|
+
primary: tuple[str, dict[str, Any]],
|
|
1111
|
+
base_dir: Path | None,
|
|
1112
|
+
) -> str | None:
|
|
1113
|
+
"""Use preview metadata to decide primary-vs-local before materializing."""
|
|
1114
|
+
|
|
1115
|
+
preview_version = self.preview_resolver_version(kind, value, base_dir)
|
|
1116
|
+
if not preview_version:
|
|
1117
|
+
return None
|
|
1118
|
+
primary_digest, primary_spec = primary
|
|
1119
|
+
primary_version = utils.get_version_from_data(primary_spec)
|
|
1120
|
+
if not primary_version:
|
|
1121
|
+
return None
|
|
1122
|
+
primary_name = primary_spec.get("name", primary_digest[:16])
|
|
1123
|
+
if utils.compare_versions(preview_version, primary_version) > 0:
|
|
1124
|
+
self.log.info(
|
|
1125
|
+
f" Resolve: {kind} v{preview_version} > published "
|
|
1126
|
+
f"{primary_name} v{primary_version} → using local "
|
|
1127
|
+
f"(skipped final comparison)"
|
|
1128
|
+
)
|
|
1129
|
+
return "local"
|
|
1130
|
+
self.log.info(
|
|
1131
|
+
f" Resolve: published {primary_name} v{primary_version} >= "
|
|
1132
|
+
f"{kind} v{preview_version} → using published (skipped generation)"
|
|
1133
|
+
)
|
|
1134
|
+
return "primary"
|
|
1135
|
+
|
|
1136
|
+
def _resolve_by_name_with_filters(
|
|
1137
|
+
self,
|
|
1138
|
+
entry: dict[str, Any],
|
|
1139
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1140
|
+
"""Resolve a component by name with optional filters."""
|
|
1141
|
+
component_name = entry["name"]
|
|
1142
|
+
publisher = entry.get("publisher")
|
|
1143
|
+
version_constraint = entry.get("version")
|
|
1144
|
+
required_annotations = entry.get("annotations")
|
|
1145
|
+
|
|
1146
|
+
search_names = [component_name, add_official_prefix(component_name)]
|
|
1147
|
+
candidates = self._api_client().find_existing_components(
|
|
1148
|
+
search_names,
|
|
1149
|
+
verbose=False,
|
|
1150
|
+
published_by=publisher,
|
|
1151
|
+
)
|
|
1152
|
+
if not candidates:
|
|
1153
|
+
self.log.info(f" Resolve: no candidates for name={component_name}")
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
if version_constraint:
|
|
1157
|
+
if not _parse_version_constraint(version_constraint):
|
|
1158
|
+
raise HydrationError(f"Invalid version constraint: '{version_constraint}'")
|
|
1159
|
+
candidates = _filter_by_version_constraint(candidates, version_constraint)
|
|
1160
|
+
if not candidates:
|
|
1161
|
+
self.log.info(
|
|
1162
|
+
f" Resolve: no candidates matching version {version_constraint}"
|
|
1163
|
+
)
|
|
1164
|
+
return None
|
|
1165
|
+
|
|
1166
|
+
if required_annotations:
|
|
1167
|
+
candidates = self._filter_by_annotations(candidates, required_annotations)
|
|
1168
|
+
if not candidates:
|
|
1169
|
+
self.log.info(
|
|
1170
|
+
f" Resolve: no candidates matching annotations {required_annotations}"
|
|
1171
|
+
)
|
|
1172
|
+
return None
|
|
1173
|
+
|
|
1174
|
+
found_digest, spec = self._find_latest_version_component(candidates)
|
|
1175
|
+
self.log.info(
|
|
1176
|
+
f" Resolve: matched {spec.get('name', 'unknown')} "
|
|
1177
|
+
f"(digest: {found_digest[:16]}...)"
|
|
1178
|
+
)
|
|
1179
|
+
return found_digest, spec
|
|
1180
|
+
|
|
1181
|
+
def _filter_by_annotations(
|
|
1182
|
+
self,
|
|
1183
|
+
candidates: list[ComponentInfo],
|
|
1184
|
+
required_annotations: dict[str, Any],
|
|
1185
|
+
) -> list[ComponentInfo]:
|
|
1186
|
+
result: list[ComponentInfo] = []
|
|
1187
|
+
for candidate in candidates:
|
|
1188
|
+
if not candidate.digest:
|
|
1189
|
+
continue
|
|
1190
|
+
try:
|
|
1191
|
+
spec_obj = self._api_client().get_component_spec(candidate.digest)
|
|
1192
|
+
except Exception as exc:
|
|
1193
|
+
response = getattr(exc, "response", None)
|
|
1194
|
+
if getattr(response, "status_code", None) == 404:
|
|
1195
|
+
continue
|
|
1196
|
+
raise
|
|
1197
|
+
attr_annotations = getattr(spec_obj, "annotations", None)
|
|
1198
|
+
if attr_annotations:
|
|
1199
|
+
annotations = attr_annotations
|
|
1200
|
+
else:
|
|
1201
|
+
try:
|
|
1202
|
+
spec_data = self.normalize_component_spec(spec_obj)
|
|
1203
|
+
except HydrationError as exc:
|
|
1204
|
+
self.log.warn(
|
|
1205
|
+
f" ⚠️ Resolve: skipping component {candidate.digest[:16]}... "
|
|
1206
|
+
f"while reading annotations: {exc}"
|
|
1207
|
+
)
|
|
1208
|
+
continue
|
|
1209
|
+
metadata = spec_data.get("metadata", {})
|
|
1210
|
+
annotations = (
|
|
1211
|
+
metadata.get("annotations", {})
|
|
1212
|
+
if isinstance(metadata, Mapping)
|
|
1213
|
+
else {}
|
|
1214
|
+
) or {}
|
|
1215
|
+
if _annotations_match(annotations, required_annotations):
|
|
1216
|
+
result.append(candidate)
|
|
1217
|
+
return result
|
|
1218
|
+
|
|
1219
|
+
def _resolve_local_file(
|
|
1220
|
+
self,
|
|
1221
|
+
local_path: str,
|
|
1222
|
+
path: str,
|
|
1223
|
+
base_dir: Path | None = None,
|
|
1224
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1225
|
+
"""Resolve a local file path to a component spec."""
|
|
1226
|
+
raw_path = local_path[7:] if local_path.startswith("file://") else local_path
|
|
1227
|
+
path_obj = Path(raw_path)
|
|
1228
|
+
if not path_obj.is_absolute() and base_dir is not None:
|
|
1229
|
+
path_obj = (base_dir / path_obj).resolve()
|
|
1230
|
+
else:
|
|
1231
|
+
path_obj = path_obj.resolve()
|
|
1232
|
+
if not path_obj.exists():
|
|
1233
|
+
if self.verbose or utils.tangle_verbose_enabled():
|
|
1234
|
+
self.log.warn(f" ⚠️ Resolve: local file not found: {path_obj}")
|
|
1235
|
+
return None
|
|
1236
|
+
file_url = local_path if local_path.startswith("file://") else f"file://{local_path}"
|
|
1237
|
+
self.log.info(f" Resolve: loading local file {local_path}")
|
|
1238
|
+
return self._fetch_component_by_url(file_url, path, base_dir)
|
|
1239
|
+
|
|
1240
|
+
def _resolve_local_from_python(
|
|
1241
|
+
self,
|
|
1242
|
+
gen_config: Any,
|
|
1243
|
+
path: str,
|
|
1244
|
+
base_dir: Path | None = None,
|
|
1245
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1246
|
+
"""Generate a component YAML from a Python source file and resolve it."""
|
|
1247
|
+
if not isinstance(gen_config, dict):
|
|
1248
|
+
self.log.warn(" ⚠️ 'local_from_python' must be a dict")
|
|
1249
|
+
return None
|
|
1250
|
+
file_field = gen_config.get("file")
|
|
1251
|
+
if not file_field:
|
|
1252
|
+
self.log.warn(" ⚠️ 'local_from_python' requires a 'file' field")
|
|
1253
|
+
return None
|
|
1254
|
+
|
|
1255
|
+
def _resolve_path(p: str | Path | None) -> Path | None:
|
|
1256
|
+
if not p:
|
|
1257
|
+
return None
|
|
1258
|
+
pp = Path(p)
|
|
1259
|
+
if pp.is_absolute():
|
|
1260
|
+
return pp
|
|
1261
|
+
return (base_dir / pp).resolve() if base_dir is not None else pp.resolve()
|
|
1262
|
+
|
|
1263
|
+
python_file = _resolve_path(file_field)
|
|
1264
|
+
if python_file is None or not python_file.exists():
|
|
1265
|
+
self.log.warn(f" ⚠️ local_from_python file not found: {python_file}")
|
|
1266
|
+
return None
|
|
1267
|
+
python_file = python_file.resolve()
|
|
1268
|
+
resolve_root = _resolve_path(gen_config.get("resolve_root"))
|
|
1269
|
+
trust_base_dirs = [base_dir, Path.cwd()]
|
|
1270
|
+
if not is_trusted_python_source(
|
|
1271
|
+
python_file,
|
|
1272
|
+
base_dirs=trust_base_dirs,
|
|
1273
|
+
trusted_sources=self.trusted_python_sources,
|
|
1274
|
+
allow_all=self.allow_all_hydration,
|
|
1275
|
+
):
|
|
1276
|
+
raise UntrustedHydrationSourceError(trusted_python_source_guidance(python_file))
|
|
1277
|
+
|
|
1278
|
+
output_folder = _resolve_path(gen_config.get("output_folder"))
|
|
1279
|
+
if output_folder is None:
|
|
1280
|
+
if base_dir is None:
|
|
1281
|
+
self.log.warn(" ⚠️ local_from_python requires output_folder")
|
|
1282
|
+
return None
|
|
1283
|
+
output_folder = (base_dir / "generated").resolve()
|
|
1284
|
+
output_folder.mkdir(parents=True, exist_ok=True)
|
|
1285
|
+
|
|
1286
|
+
out_path = output_folder / (python_file.stem.replace("_", "-") + ".yaml")
|
|
1287
|
+
generation_kwargs = {
|
|
1288
|
+
"python_file": python_file,
|
|
1289
|
+
"output_path": out_path,
|
|
1290
|
+
"function_name": gen_config.get("function"),
|
|
1291
|
+
"custom_name": gen_config.get("name"),
|
|
1292
|
+
"image": gen_config.get("image"),
|
|
1293
|
+
"dependencies_from": _resolve_path(gen_config.get("dependencies_from")),
|
|
1294
|
+
"strip_code": bool(gen_config.get("strip_code", False)),
|
|
1295
|
+
"mode": str(gen_config.get("mode", "inline")),
|
|
1296
|
+
"resolve_root": resolve_root,
|
|
1297
|
+
}
|
|
1298
|
+
if self.component_generator is not None:
|
|
1299
|
+
success = self.component_generator.regenerate_yaml(**generation_kwargs)
|
|
1300
|
+
else:
|
|
1301
|
+
success = regenerate_yaml(**generation_kwargs, logger=self.log)
|
|
1302
|
+
if not success or not out_path.exists():
|
|
1303
|
+
self.log.warn(f" ⚠️ local_from_python failed to generate {out_path}")
|
|
1304
|
+
return None
|
|
1305
|
+
return self._resolve_local_file(str(out_path), path, base_dir)
|
|
1306
|
+
|
|
1307
|
+
def _pick_higher_version(
|
|
1308
|
+
self,
|
|
1309
|
+
primary: tuple[str, dict[str, Any]],
|
|
1310
|
+
local: tuple[str, dict[str, Any]],
|
|
1311
|
+
path: str,
|
|
1312
|
+
) -> tuple[str, dict[str, Any]]:
|
|
1313
|
+
primary_version = utils.get_version_from_data(primary[1])
|
|
1314
|
+
local_version = utils.get_version_from_data(local[1])
|
|
1315
|
+
if local_version and primary_version:
|
|
1316
|
+
if utils.compare_versions(local_version, primary_version) > 0:
|
|
1317
|
+
return local
|
|
1318
|
+
return primary
|
|
1319
|
+
if local_version and not primary_version:
|
|
1320
|
+
return local
|
|
1321
|
+
return primary
|
|
1322
|
+
|
|
1323
|
+
def _resolve_task(
|
|
1324
|
+
self,
|
|
1325
|
+
task_name: str,
|
|
1326
|
+
task_data: dict[str, Any],
|
|
1327
|
+
path: str,
|
|
1328
|
+
base_dir: Path | None = None,
|
|
1329
|
+
recursive_params: dict[str, Any] | None = None,
|
|
1330
|
+
) -> dict[str, Any]:
|
|
1331
|
+
"""Resolve component references to full componentRef with spec."""
|
|
1332
|
+
if recursive_params is not None:
|
|
1333
|
+
self._global_params = recursive_params
|
|
1334
|
+
else:
|
|
1335
|
+
self._global_params = {}
|
|
1336
|
+
if not isinstance(task_data, dict):
|
|
1337
|
+
return task_data
|
|
1338
|
+
|
|
1339
|
+
legacy_mappings = [
|
|
1340
|
+
("componentUrl", "url"),
|
|
1341
|
+
("componentName", "name"),
|
|
1342
|
+
("componentDigest", "digest"),
|
|
1343
|
+
]
|
|
1344
|
+
for legacy_key, ref_type in legacy_mappings:
|
|
1345
|
+
if legacy_key in task_data:
|
|
1346
|
+
ref_value = task_data[legacy_key]
|
|
1347
|
+
if ref_value and self.enable_resolution:
|
|
1348
|
+
return self._resolve_component_ref(
|
|
1349
|
+
task_name,
|
|
1350
|
+
task_data,
|
|
1351
|
+
path,
|
|
1352
|
+
ref_type,
|
|
1353
|
+
ref_value,
|
|
1354
|
+
remove_key=legacy_key,
|
|
1355
|
+
base_dir=base_dir,
|
|
1356
|
+
)
|
|
1357
|
+
return task_data
|
|
1358
|
+
|
|
1359
|
+
if "componentRef" not in task_data:
|
|
1360
|
+
return task_data
|
|
1361
|
+
component_ref = task_data["componentRef"]
|
|
1362
|
+
if not isinstance(component_ref, dict) or "spec" in component_ref:
|
|
1363
|
+
return task_data
|
|
1364
|
+
if not self.enable_resolution:
|
|
1365
|
+
return task_data
|
|
1366
|
+
|
|
1367
|
+
present_refs = [
|
|
1368
|
+
(key, component_ref[key])
|
|
1369
|
+
for key in ("digest", "name", "url")
|
|
1370
|
+
if key in component_ref and component_ref[key]
|
|
1371
|
+
]
|
|
1372
|
+
if not present_refs:
|
|
1373
|
+
return task_data
|
|
1374
|
+
if len(present_refs) == 1:
|
|
1375
|
+
ref_type, ref_value = present_refs[0]
|
|
1376
|
+
return self._resolve_component_ref(
|
|
1377
|
+
task_name,
|
|
1378
|
+
task_data,
|
|
1379
|
+
path,
|
|
1380
|
+
ref_type,
|
|
1381
|
+
ref_value,
|
|
1382
|
+
remove_key="componentRef",
|
|
1383
|
+
base_dir=base_dir,
|
|
1384
|
+
)
|
|
1385
|
+
return self._resolve_best_ref(task_name, task_data, path, present_refs, base_dir)
|
|
1386
|
+
|
|
1387
|
+
def _resolve_component_ref(
|
|
1388
|
+
self,
|
|
1389
|
+
task_name: str,
|
|
1390
|
+
task_data: dict[str, Any],
|
|
1391
|
+
path: str,
|
|
1392
|
+
ref_type: str,
|
|
1393
|
+
ref_value: str,
|
|
1394
|
+
remove_key: str,
|
|
1395
|
+
base_dir: Path | None = None,
|
|
1396
|
+
) -> dict[str, Any]:
|
|
1397
|
+
"""Resolve a component reference to full componentRef with spec."""
|
|
1398
|
+
cache_key = self._cache_key(ref_type, ref_value, base_dir)
|
|
1399
|
+
if cache_key not in self.cache:
|
|
1400
|
+
result = self._resolve_registered_component(ref_type, ref_value, path, base_dir)
|
|
1401
|
+
if result is None:
|
|
1402
|
+
if not self._postprocess_callback:
|
|
1403
|
+
raise HydrationError(f"Component not found: {ref_type}={ref_value} at {path}")
|
|
1404
|
+
processed = self._postprocess_callback(task_name, task_data, path)
|
|
1405
|
+
component_ref = processed.get("componentRef")
|
|
1406
|
+
if not component_ref:
|
|
1407
|
+
raise HydrationError(f"Component not found: {ref_type}={ref_value} at {path}")
|
|
1408
|
+
else:
|
|
1409
|
+
digest, spec = result
|
|
1410
|
+
component_ref = {
|
|
1411
|
+
"name": spec.get("name", ""),
|
|
1412
|
+
"digest": digest,
|
|
1413
|
+
"spec": spec,
|
|
1414
|
+
}
|
|
1415
|
+
if self._postprocess_callback:
|
|
1416
|
+
new_task = {k: v for k, v in task_data.items() if k != remove_key}
|
|
1417
|
+
new_task["componentRef"] = component_ref
|
|
1418
|
+
processed = self._postprocess_callback(task_name, new_task, path)
|
|
1419
|
+
component_ref = processed.get("componentRef", component_ref)
|
|
1420
|
+
self.cache[cache_key] = component_ref
|
|
1421
|
+
|
|
1422
|
+
new_task = {k: v for k, v in task_data.items() if k != remove_key}
|
|
1423
|
+
new_task["componentRef"] = copy.deepcopy(self.cache[cache_key])
|
|
1424
|
+
return new_task
|
|
1425
|
+
|
|
1426
|
+
def _try_resolve_single_ref(
|
|
1427
|
+
self,
|
|
1428
|
+
ref_type: str,
|
|
1429
|
+
ref_value: str,
|
|
1430
|
+
path: str,
|
|
1431
|
+
base_dir: Path | None,
|
|
1432
|
+
) -> tuple[str, str, str | None, dict[str, Any]] | None:
|
|
1433
|
+
"""Resolve one ref and return metadata for best-ref selection."""
|
|
1434
|
+
cache_key = self._cache_key(ref_type, ref_value, base_dir)
|
|
1435
|
+
try:
|
|
1436
|
+
if cache_key not in self.cache:
|
|
1437
|
+
result = self._resolve_registered_component(ref_type, ref_value, path, base_dir)
|
|
1438
|
+
if result is None:
|
|
1439
|
+
self.log.warn(f" ⚠️ Could not resolve {ref_type}={ref_value}")
|
|
1440
|
+
return None
|
|
1441
|
+
digest, spec = result
|
|
1442
|
+
self.cache[cache_key] = {
|
|
1443
|
+
"name": spec.get("name", ""),
|
|
1444
|
+
"digest": digest,
|
|
1445
|
+
"spec": spec,
|
|
1446
|
+
}
|
|
1447
|
+
component_ref = self.cache[cache_key]
|
|
1448
|
+
version = utils.get_version_from_data(component_ref.get("spec", {}))
|
|
1449
|
+
return (ref_type, ref_value, version, component_ref)
|
|
1450
|
+
except Exception as exc:
|
|
1451
|
+
self.log.warn(f" ⚠️ Failed to resolve {ref_type}={ref_value}: {exc}")
|
|
1452
|
+
return None
|
|
1453
|
+
|
|
1454
|
+
@staticmethod
|
|
1455
|
+
def _pick_best_candidate(
|
|
1456
|
+
candidates: list[tuple[str, str, str | None, dict[str, Any]]],
|
|
1457
|
+
) -> tuple[str, str, str | None, dict[str, Any]]:
|
|
1458
|
+
"""Pick candidate with highest version; tie-break digest > name > url."""
|
|
1459
|
+
priority = {"digest": 0, "name": 1, "url": 2}
|
|
1460
|
+
|
|
1461
|
+
def _is_better(candidate, current):
|
|
1462
|
+
c_type, _, c_ver, _ = candidate
|
|
1463
|
+
b_type, _, b_ver, _ = current
|
|
1464
|
+
if c_ver and b_ver:
|
|
1465
|
+
cmp = utils.compare_versions(c_ver, b_ver)
|
|
1466
|
+
if cmp != 0:
|
|
1467
|
+
return cmp > 0
|
|
1468
|
+
elif c_ver and not b_ver:
|
|
1469
|
+
return True
|
|
1470
|
+
elif not c_ver and b_ver:
|
|
1471
|
+
return False
|
|
1472
|
+
return priority.get(c_type, 99) < priority.get(b_type, 99)
|
|
1473
|
+
|
|
1474
|
+
best = candidates[0]
|
|
1475
|
+
for candidate in candidates[1:]:
|
|
1476
|
+
if _is_better(candidate, best):
|
|
1477
|
+
best = candidate
|
|
1478
|
+
return best
|
|
1479
|
+
|
|
1480
|
+
def _resolve_best_ref(
|
|
1481
|
+
self,
|
|
1482
|
+
task_name: str,
|
|
1483
|
+
task_data: dict[str, Any],
|
|
1484
|
+
path: str,
|
|
1485
|
+
refs: list[tuple[str, str]],
|
|
1486
|
+
base_dir: Path | None = None,
|
|
1487
|
+
) -> dict[str, Any]:
|
|
1488
|
+
"""Resolve multiple refs and pick the highest version."""
|
|
1489
|
+
candidates = []
|
|
1490
|
+
for ref_type, ref_value in refs:
|
|
1491
|
+
result = self._try_resolve_single_ref(ref_type, ref_value, path, base_dir)
|
|
1492
|
+
if result:
|
|
1493
|
+
candidates.append(result)
|
|
1494
|
+
if not candidates:
|
|
1495
|
+
ref_type, ref_value = refs[0]
|
|
1496
|
+
return self._resolve_component_ref(
|
|
1497
|
+
task_name,
|
|
1498
|
+
task_data,
|
|
1499
|
+
path,
|
|
1500
|
+
ref_type,
|
|
1501
|
+
ref_value,
|
|
1502
|
+
remove_key="componentRef",
|
|
1503
|
+
base_dir=base_dir,
|
|
1504
|
+
)
|
|
1505
|
+
_chosen_type, _chosen_value, _chosen_version, chosen_ref = self._pick_best_candidate(
|
|
1506
|
+
candidates
|
|
1507
|
+
)
|
|
1508
|
+
new_task = {k: v for k, v in task_data.items() if k != "componentRef"}
|
|
1509
|
+
new_task["componentRef"] = copy.deepcopy(chosen_ref)
|
|
1510
|
+
return new_task
|
|
1511
|
+
|
|
1512
|
+
def resolve_components(
|
|
1513
|
+
self,
|
|
1514
|
+
data: dict[str, Any],
|
|
1515
|
+
base_dir: Path | None = None,
|
|
1516
|
+
) -> dict[str, Any]:
|
|
1517
|
+
"""Traverse pipeline YAML and resolve componentRef references."""
|
|
1518
|
+
|
|
1519
|
+
def process_task(
|
|
1520
|
+
task_name: str,
|
|
1521
|
+
task_data: dict[str, Any],
|
|
1522
|
+
path: str,
|
|
1523
|
+
task_base_dir: Path | None = None,
|
|
1524
|
+
recursive_params: dict[str, Any] | None = None,
|
|
1525
|
+
) -> dict[str, Any]:
|
|
1526
|
+
return self._resolve_task(
|
|
1527
|
+
task_name, task_data, path, task_base_dir, recursive_params
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
pipeline_name = data.get("name", "pipeline")
|
|
1531
|
+
initial_params = self._global_params.copy() if self._global_params else None
|
|
1532
|
+
return utils.traverse_pipeline_tasks(
|
|
1533
|
+
data, pipeline_name, process_task, base_dir, initial_params
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
@property
|
|
1537
|
+
def resolved_count(self) -> int:
|
|
1538
|
+
"""Return the number of resolved components."""
|
|
1539
|
+
return len(self.cache)
|
|
1540
|
+
|
|
1541
|
+
def hydrate_file(
|
|
1542
|
+
self,
|
|
1543
|
+
input_file: Path | str,
|
|
1544
|
+
output_file: Path | str | None = None,
|
|
1545
|
+
overrides: dict[str, str] | None = None,
|
|
1546
|
+
) -> HydratedPipeline:
|
|
1547
|
+
"""Hydrate a pipeline YAML file."""
|
|
1548
|
+
self._global_params = {}
|
|
1549
|
+
try:
|
|
1550
|
+
input_str = str(input_file)
|
|
1551
|
+
input_scheme = self._uri_scheme(input_str)
|
|
1552
|
+
input_path: Path | None = None
|
|
1553
|
+
base_dir: Path | None = None
|
|
1554
|
+
try:
|
|
1555
|
+
if input_scheme and input_scheme != "file":
|
|
1556
|
+
yaml_text = self._read_uri_text(
|
|
1557
|
+
input_str,
|
|
1558
|
+
"pipeline",
|
|
1559
|
+
self.make_resolver_context(input_scheme, input_str, "pipeline", None),
|
|
1560
|
+
)
|
|
1561
|
+
config = yaml.safe_load(yaml_text) if yaml_text is not None else None
|
|
1562
|
+
else:
|
|
1563
|
+
raw_input = input_str[7:] if input_str.startswith("file://") else input_str
|
|
1564
|
+
input_path = Path(raw_input)
|
|
1565
|
+
base_dir = input_path.parent.resolve()
|
|
1566
|
+
config = yaml.safe_load(input_path.read_text(encoding="utf-8"))
|
|
1567
|
+
except Exception as exc:
|
|
1568
|
+
raise HydrationError(f"Failed to read pipeline YAML {input_file}: {exc}") from exc
|
|
1569
|
+
if config is None:
|
|
1570
|
+
config = {}
|
|
1571
|
+
if not isinstance(config, dict):
|
|
1572
|
+
raise HydrationError("Pipeline YAML must contain a top-level mapping")
|
|
1573
|
+
|
|
1574
|
+
if "template_file" in config:
|
|
1575
|
+
if input_path is None:
|
|
1576
|
+
raise UnsupportedHydrationFeatureError(
|
|
1577
|
+
"template_file configs require a local pipeline input"
|
|
1578
|
+
)
|
|
1579
|
+
result = self._render_template_config(input_path, config, overrides=overrides)
|
|
1580
|
+
if result is None:
|
|
1581
|
+
raise HydrationError(
|
|
1582
|
+
f"Template file not found: {(base_dir / config['template_file']).resolve()}"
|
|
1583
|
+
)
|
|
1584
|
+
_, output_yaml = result
|
|
1585
|
+
if self.recursive_context:
|
|
1586
|
+
self._global_params = {k: v for k, v in config.items() if k != "template_file"}
|
|
1587
|
+
if overrides:
|
|
1588
|
+
self._global_params.update(overrides)
|
|
1589
|
+
self.log.info(f"✅ Hydrated {input_file}")
|
|
1590
|
+
else:
|
|
1591
|
+
output_yaml = config
|
|
1592
|
+
self.log.info(f"✅ Copied {input_file}")
|
|
1593
|
+
|
|
1594
|
+
output_yaml = self.resolve_components(output_yaml, base_dir=base_dir)
|
|
1595
|
+
output_content = utils.dump_yaml(output_yaml)
|
|
1596
|
+
if output_file is not None:
|
|
1597
|
+
output_str = str(output_file)
|
|
1598
|
+
output_scheme = self._uri_scheme(output_str)
|
|
1599
|
+
if output_scheme and output_scheme != "file":
|
|
1600
|
+
self._write_uri_text(
|
|
1601
|
+
output_str,
|
|
1602
|
+
output_content,
|
|
1603
|
+
self.make_resolver_context(output_scheme, output_str, "output", base_dir),
|
|
1604
|
+
)
|
|
1605
|
+
else:
|
|
1606
|
+
raw_output = output_str[7:] if output_str.startswith("file://") else output_str
|
|
1607
|
+
output_path = Path(raw_output)
|
|
1608
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1609
|
+
output_path.write_text(output_content, encoding="utf-8")
|
|
1610
|
+
return HydratedPipeline(output_yaml, output_content, self.resolved_count)
|
|
1611
|
+
finally:
|
|
1612
|
+
self._global_params = {}
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
# =============================================================================
|
|
1616
|
+
# Resolve config helpers (ported from TD)
|
|
1617
|
+
# =============================================================================
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _annotations_match(annotations: Mapping[str, Any], required: dict[str, Any]) -> bool:
|
|
1621
|
+
"""Check if a component's annotations satisfy all required constraints."""
|
|
1622
|
+
for key, expected in required.items():
|
|
1623
|
+
actual = annotations.get(key)
|
|
1624
|
+
if isinstance(expected, list):
|
|
1625
|
+
if actual not in [str(v) for v in expected]:
|
|
1626
|
+
return False
|
|
1627
|
+
else:
|
|
1628
|
+
if actual != str(expected):
|
|
1629
|
+
return False
|
|
1630
|
+
return True
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _parse_version_constraint(constraint: str) -> list[tuple[str, str]]:
|
|
1634
|
+
"""Parse a version constraint string into ``(operator, version)`` pairs."""
|
|
1635
|
+
parts = [p.strip() for p in constraint.split(",") if p.strip()]
|
|
1636
|
+
result: list[tuple[str, str]] = []
|
|
1637
|
+
for part in parts:
|
|
1638
|
+
match = re.match(r"^(>=|<=|!=|>|<|==)?\s*(\d[\d.]*)", part)
|
|
1639
|
+
if match:
|
|
1640
|
+
op = match.group(1) or "=="
|
|
1641
|
+
version = match.group(2)
|
|
1642
|
+
result.append((op, version))
|
|
1643
|
+
return result
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _version_satisfies(version: str, constraint: str) -> bool:
|
|
1647
|
+
"""Check if a version string satisfies a constraint."""
|
|
1648
|
+
parsed = _parse_version_constraint(constraint)
|
|
1649
|
+
if not parsed:
|
|
1650
|
+
raise HydrationError(f"Invalid version constraint: '{constraint}'")
|
|
1651
|
+
|
|
1652
|
+
for op, target in parsed:
|
|
1653
|
+
cmp = utils.compare_versions(version, target)
|
|
1654
|
+
satisfied = (
|
|
1655
|
+
(op == ">=" and cmp >= 0)
|
|
1656
|
+
or (op == ">" and cmp > 0)
|
|
1657
|
+
or (op == "<=" and cmp <= 0)
|
|
1658
|
+
or (op == "<" and cmp < 0)
|
|
1659
|
+
or (op == "==" and cmp == 0)
|
|
1660
|
+
or (op == "!=" and cmp != 0)
|
|
1661
|
+
)
|
|
1662
|
+
if not satisfied:
|
|
1663
|
+
return False
|
|
1664
|
+
return True
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _filter_by_version_constraint(
|
|
1668
|
+
candidates: list[ComponentInfo],
|
|
1669
|
+
constraint: str,
|
|
1670
|
+
) -> list[ComponentInfo]:
|
|
1671
|
+
"""Filter candidates whose version satisfies a constraint string."""
|
|
1672
|
+
return [
|
|
1673
|
+
c for c in candidates
|
|
1674
|
+
if c.version and _version_satisfies(c.version, constraint)
|
|
1675
|
+
]
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
class DehydrateChoice:
|
|
1679
|
+
"""Constants preserved from TD for future dehydrate porting."""
|
|
1680
|
+
|
|
1681
|
+
DIGEST = "d"
|
|
1682
|
+
NAME = "n"
|
|
1683
|
+
URL = "u"
|
|
1684
|
+
FILE = "f"
|
|
1685
|
+
KEEP = "k"
|
|
1686
|
+
AUTO = "a"
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
# ---- Resolver registry -----------------------------------------------------
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def _resolve_digest(
|
|
1693
|
+
hydrator: PipelineHydrator,
|
|
1694
|
+
value: Any,
|
|
1695
|
+
path: str,
|
|
1696
|
+
base_dir: Path | None,
|
|
1697
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1698
|
+
return hydrator._fetch_component_by_digest(str(value), path, base_dir)
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _resolve_name(
|
|
1702
|
+
hydrator: PipelineHydrator,
|
|
1703
|
+
value: Any,
|
|
1704
|
+
path: str,
|
|
1705
|
+
base_dir: Path | None,
|
|
1706
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1707
|
+
return hydrator._fetch_component_by_name(str(value), path, base_dir)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _resolve_url(
|
|
1711
|
+
hydrator: PipelineHydrator,
|
|
1712
|
+
value: Any,
|
|
1713
|
+
path: str,
|
|
1714
|
+
base_dir: Path | None,
|
|
1715
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1716
|
+
return hydrator._fetch_component_by_url(str(value), path, base_dir)
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _resolve_file(
|
|
1720
|
+
hydrator: PipelineHydrator,
|
|
1721
|
+
value: Any,
|
|
1722
|
+
path: str,
|
|
1723
|
+
base_dir: Path | None,
|
|
1724
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1725
|
+
return hydrator._fetch_component_from_file_url(str(value), path, base_dir)
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _resolve_resolve(
|
|
1729
|
+
hydrator: PipelineHydrator,
|
|
1730
|
+
value: Any,
|
|
1731
|
+
path: str,
|
|
1732
|
+
base_dir: Path | None,
|
|
1733
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1734
|
+
return hydrator._fetch_component_by_resolve_url(str(value), path, base_dir)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _resolve_http(
|
|
1738
|
+
hydrator: PipelineHydrator,
|
|
1739
|
+
value: Any,
|
|
1740
|
+
path: str,
|
|
1741
|
+
base_dir: Path | None,
|
|
1742
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1743
|
+
return hydrator.fetch_remote_component(str(value), path, base_dir)
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _read_http_uri(
|
|
1747
|
+
hydrator: PipelineHydrator,
|
|
1748
|
+
uri: str,
|
|
1749
|
+
context: ResolverContext,
|
|
1750
|
+
) -> str | None:
|
|
1751
|
+
del context
|
|
1752
|
+
hydrator.log.info(f" Downloading URI: {uri}...")
|
|
1753
|
+
with urllib.request.urlopen(uri, timeout=30) as response:
|
|
1754
|
+
return response.read().decode("utf-8")
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _resolve_local(
|
|
1758
|
+
hydrator: PipelineHydrator,
|
|
1759
|
+
value: Any,
|
|
1760
|
+
path: str,
|
|
1761
|
+
base_dir: Path | None,
|
|
1762
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1763
|
+
return hydrator._resolve_local_file(str(value), path, base_dir)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _resolve_local_from_python(
|
|
1767
|
+
hydrator: PipelineHydrator,
|
|
1768
|
+
value: Any,
|
|
1769
|
+
path: str,
|
|
1770
|
+
base_dir: Path | None,
|
|
1771
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
1772
|
+
return hydrator._resolve_local_from_python(value, path, base_dir)
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
register_component_resolver("digest", _resolve_digest)
|
|
1776
|
+
register_component_resolver("name", _resolve_name)
|
|
1777
|
+
register_component_resolver("url", _resolve_url)
|
|
1778
|
+
register_component_resolver("file", _resolve_file)
|
|
1779
|
+
register_component_resolver("resolve", _resolve_resolve)
|
|
1780
|
+
register_component_resolver("http", _resolve_http)
|
|
1781
|
+
register_component_resolver("https", _resolve_http)
|
|
1782
|
+
register_component_resolver("local", _resolve_local)
|
|
1783
|
+
register_component_resolver("local_from_python", _resolve_local_from_python)
|
|
1784
|
+
register_uri_reader("http", _read_http_uri)
|
|
1785
|
+
register_uri_reader("https", _read_http_uri)
|