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.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. 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)