diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py b/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py new file mode 100644 index 0000000..06e78c7 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py @@ -0,0 +1,2226 @@ +"""Compile a Python-authored Tangle pipeline to a single YAML file. + +Surface:: + + tangle sdk pipelines compile -o [--pipeline name] [--override key=value ...] + +The compile driver: + +1. Loads ``pipeline.py`` as a Python module via ``importlib``. +2. Selects the root :class:`PipelineFn` via ``vars(mod).values()`` — the + single one defined in the file, or the one named by ``--pipeline`` when + the file defines several (e.g. a parent + same-file nested children). +3. Loads the cfg from ``/`` and overlays + ``--override key=value`` pairs (CLI wins). ``cfg`` is compile-time + only — its values are baked into emitted constants, never copied + into the output YAML. +4. Traces the pipeline and emits the body dict. +5. Writes the single dehydrated pipeline YAML. No wrapper config and no + ``.yaml.j2`` template sidecar are written. When the pipeline uses + ``@task``-decorated components, an auxiliary ``.components.yaml`` + resolver sidecar is also written and each ``@task`` task's + ``componentRef`` is rewritten to a pure + ``resolve://./.components.yaml#`` URL. + +Exit codes (surfaced by the CLI layer): +- 0 — success. +- 1 — :class:`tangle_cli.python_pipeline.errors.CompileError`. + +The generic compile logic lives here as free functions plus the +:class:`PipelineCompiler` command handler (a :class:`TangleCliHandler` +subclass, mirroring :class:`tangle_cli.pipeline_hydrator.PipelineHydrator`). +Distributions that carry a zone concept extend the :data:`ZONE_ROOT_MARKERS` +seam so the compiler can resolve an explicit relative +``@registered(gen_config="rel/path")`` against a zone root. +""" + +from __future__ import annotations + +import importlib.util +import inspect +import os +import re +import sys +import uuid +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, get_type_hints + +import yaml + +from .handler import TangleCliHandler +from .python_pipeline.cfg import Cfg, _coerce_override, load_cfg +from .python_pipeline.compiler_context import ( + BroadcastLayer, + CompileContext, + PipelineCompileKey, + SubgraphArtifact, + canonical_repo_path, + overrides_fingerprint, +) +from .python_pipeline.emit import _TASK_URL_PLACEHOLDER, emit_pipeline +from .python_pipeline.errors import CompileError +from .python_pipeline.pipeline import PipelineFn +from .python_pipeline.ref import CallableRef +from .python_pipeline.registered import _REGISTERED_URL_PLACEHOLDER +from .python_pipeline.subpipeline import _SUBPIPELINE_URL_PLACEHOLDER, SubpipelineRef +from .python_pipeline.trace import trace_pipeline +from .python_pipeline.types import In +from .schema_validation import SchemaValidationError, validate_dehydrated_pipeline +from .utils import dump_yaml + + +@dataclass +class CompileResult: + """Outcome of a :func:`compile_pipeline` call. + + Attributes: + pipeline_path: Resolved path of the single emitted pipeline YAML. + components_path: Path of the ``@task`` ``local_from_python`` + resolver sidecar, or ``None`` when no sidecar was emitted. + task_count: Number of tasks traced into the ROOT graph. + warnings: Non-fatal messages collected during compilation. + subgraph_paths: Paths of the child graph sidecars written under + ``.subgraphs/`` for nested ``subpipeline(...)`` calls. + Empty for single-pipeline / ``@task``-only compiles. Additive + field — existing callers that ignore it are unaffected. + """ + + pipeline_path: Path + components_path: Path | None = None + task_count: int = 0 + warnings: list[str] = field(default_factory=list) + subgraph_paths: list[Path] = field(default_factory=list) + + +def compile_pipeline( + script: Path, + output: Path, + overrides: Mapping[str, str] | None = None, + *, + pipeline_name: str | None = None, + emit_components_sidecar: bool = True, +) -> CompileResult: + """Compile ``script`` to a single pipeline YAML at ``output``. + + Args: + script: Path to the Python authoring file. The file holds the + single root ``@pipeline``-decorated function, or several + (a parent + same-file nested children, or independent + siblings) one of which is selected by ``pipeline_name``. + output: Path for the compiled pipeline YAML. The dehydrated + pipeline body is always written here. When the pipeline uses + ``@task`` components an additional ``.components.yaml`` + resolver sidecar is written alongside it. No wrapper config + and no ``.yaml.j2`` sidecar are ever written. + overrides: Already-parsed ``key=value`` config overrides. CLI + wins over file-defined values. + pipeline_name: When the file defines several pipelines, selects + which root to compile — matched against the decorated + function ``__name__`` first, then the ``@pipeline`` display + name (``--pipeline``). Optional (and ignored unless it must + disambiguate) when the file defines exactly one pipeline. + Same-file nested children are reached via the selected root's + ``subpipeline(...)`` calls, not by this name. + emit_components_sidecar: When ``True`` (the default), ``@task`` + components are preserved by emitting a sibling + ``.components.yaml`` ``local_from_python`` resolver and + rewriting their ``componentRef`` to a pure ``resolve://`` URL. + When ``False`` and the pipeline uses ``@task`` components, + compilation fails with a :class:`CompileError` (a valid + dehydrated pipeline cannot be produced without the sidecar). + Pipelines that use only ``ref(url=...)`` are unaffected. + + Returns: + A :class:`CompileResult`. ``components_path`` is the sidecar path + when one was written, else ``None``. + + Raises: + CompileError: for user-facing problems (missing script, no/ + multiple ``@pipeline`` functions, missing/invalid config, + unreachable ``@task`` source/dependency files). + """ + overrides = dict(overrides or {}) + + # 1. Validate the script path. + script_path = Path(script).resolve() + if not script_path.exists(): + raise CompileError(f"pipeline file not found: {script}") + + # The root module is exec'd under a unique synthetic name (restored by + # ``_load_pipeline_fn``), but its top-level ``from sibling import ...`` + # statements (and trace-time sibling imports inside cycle-style children) + # register sibling modules under their REAL names; left behind they would + # let a SUBSEQUENT in-process compile of a DIFFERENT bundle reuse the + # STALE cached sibling. We purge bundle-local modules AFTER the full + # compile, so the already-captured child ``PipelineFn`` objects + # (referenced by the traced functions) stay valid throughout (P2 fix). + # Collect every source directory the compile imports from (root + each + # child's own source dir, which may differ from the root's) so the purge + # also reaches children authored in sibling directories. + purge_dirs: set[Path] = {script_path.parent.resolve()} + + # Python caches imported modules by NAME (not path), so a PRE-EXISTING + # ``sys.modules`` entry for a bundle-local sibling (left by a prior + # in-process compile, a test helper, or a REPL) would shadow this bundle's + # OWN sibling file. Evict any such shadowing entry up front so the root's + # ``from sibling import ...`` resolves FRESH from this bundle (P2 fix, the + # pre-existing-pollution half; the after-compile purge below handles the + # entries this compile itself adds). + _evict_shadowed_bundle_modules(script_path.parent) + + try: + # 2. Load the user pipeline module + select the ROOT PipelineFn. When + # the file defines several pipelines, ``pipeline_name`` + # (``--pipeline``) chooses which to emit (Decision G). Imported + # child pipelines are reachable to ``subpipeline(child)`` but are + # never selectable compile targets; same-file nested children are + # compiled via the ``subpipeline`` recursion below. + pipeline_fn = _load_pipeline_fn(script_path, pipeline_name) + + # 3. Resolve the root output path up front. Child subgraph sidecars are + # written under ``.subgraphs/`` next to it, and the relative + # ``file://`` / ``resolve://`` URLs that point at the bundle are + # derived from these paths. + output_path = output.resolve() if output.is_absolute() else (Path.cwd() / output).resolve() + + # 4. Build the recursive compile context. ALL artifacts (root + every + # nested child sidecar) are traced, emitted, and rewritten IN MEMORY + # first; nothing is written until the whole bundle validates + # (Decision C). ``planned_files`` lets asset-policy validation accept + # refs to sidecars the compiler is about to write (Decision J). + ctx = CompileContext( + root_output_path=output_path, + subgraph_dir=output_path.with_name(output_path.stem + ".subgraphs"), + root_overrides=overrides, + emit_components_sidecar=emit_components_sidecar, + source_dirs=purge_dirs, + ) + + # 5. Compile the root (and, recursively, all children) into in-memory + # artifacts. The root cfg is resolved relative to the script's own + # directory; each child's cfg is resolved relative to ITS OWN source + # directory inside ``_compile_pipeline_fn`` (Decision F). + root_artifact = _compile_pipeline_fn( + pipeline_fn, + output_path, + ctx, + overrides, + is_root=True, + base_dir=script_path.parent, + ) + + # 6. The full bundle: the root plus every deduped child in the registry. + # Keying children by compile key means a child reached twice (or via + # a diamond) appears exactly once (Decision M). + artifacts: list[SubgraphArtifact] = [root_artifact, *ctx.registry.values()] + + # 7. Validate EVERY artifact before writing ANY file. Per artifact: + # validate_dehydrated_pipeline (top-level guard + schema + + # no-template scan + semantic checks) -> dump -> reload -> validate + # again -> asset policy. On any failure nothing is written + # (Decision C / J). + for artifact in artifacts: + _validate_artifact(artifact, ctx) + + # 8. Write the whole bundle only after every artifact validated. + for artifact in artifacts: + _write_artifact(artifact) + + return CompileResult( + pipeline_path=root_artifact.output_path, + components_path=root_artifact.components_path, + task_count=root_artifact.task_count, + warnings=ctx.warnings, + subgraph_paths=[a.output_path for a in artifacts if not a.is_root], + ) + finally: + # Purge bundle-local modules so a subsequent in-process compile + # re-imports them fresh (P2). Runs on success AND on error. + _purge_bundle_local_modules(purge_dirs) + + +# --------------------------------------------------------------------------- +# Recursive in-memory compile (Decision C) + + +@contextmanager +def _temp_sys_path(directory: Path) -> Iterator[None]: + """Temporarily put ``directory`` on ``sys.path`` (leak-free). + + The root module is exec'd by :func:`_load_pipeline_fn` with its source + dir on ``sys.path``, but that entry is removed before tracing. Some + fixtures defer sibling imports to TRACE time (e.g. the cycle fixtures + ``from cycle_b import ...`` inside the body), so the source dir must be + importable while the body runs. The entry is restored on exit so + repeated/concurrent compiles never leak global import state. + """ + d = str(directory) + added = d not in sys.path + if added: + sys.path.insert(0, d) + try: + yield + finally: + if added: + try: + sys.path.remove(d) + except ValueError: # pragma: no cover — defensive + pass + + +def _compile_pipeline_fn( + pipeline_fn: PipelineFn, + output_path: Path, + ctx: CompileContext, + overrides: Mapping[str, Any], + *, + is_root: bool, + base_dir: Path, + rebroadcast_overrides: Mapping[str, Any] | None = None, +) -> SubgraphArtifact: + """Trace + emit ``pipeline_fn`` into an in-memory :class:`SubgraphArtifact`. + + Does NOT write any file. The given ``pipeline_fn`` is ALREADY imported + (the root via :func:`_load_pipeline_fn`; a child via its parent module's + ``import``), so it is traced directly — the module is never reloaded, + which sidesteps ``sys.modules`` cache issues with sibling children. + + Steps: + + 1. Resolve + load cfg relative to ``base_dir`` (the pipeline's OWN + source dir — Decision F config isolation). + 2. Trace in a fresh ``GraphBuilder`` and emit the dehydrated body. + 3. Build (do NOT write) this artifact's ``@task`` ``local_from_python`` + components sidecar and rewrite its ``@task`` refs to pure + ``resolve://`` URLs. + 4. (Phase 4) compile any ``subpipeline(child)`` children recursively and + rewrite the parent task refs to pure ``file://`` URLs. + + Returns the planned artifact; validation + writing happen later in + :func:`compile_pipeline` once the whole bundle is built. + """ + # Record this pipeline's source dir so the driver can purge any modules + # it imports from there after the compile (P2 sibling-import leak fix). + try: + ctx.source_dirs.add(base_dir.resolve()) + except OSError: # pragma: no cover — defensive + pass + + # Evict any stale modules that would shadow THIS pipeline's bundle-local + # siblings before tracing, so trace-time sibling imports inside the body + # (e.g. cycle-style ``from sibling import ...``) resolve fresh from the + # bundle rather than a cached entry pointing elsewhere (P2 fix). + _evict_shadowed_bundle_modules(base_dir) + + # 1. cfg is resolved + loaded relative to the pipeline's own source dir. + # The ROOT coerces its CLI ``--override`` strings (yaml.safe_load); a + # CHILD receives already-typed native ``effective`` overrides (broadcast + # + explicit ``.override_config``) that must pass through unchanged. + cfg_path = _resolve_cfg_path_in_dir(pipeline_fn, base_dir) + uses_cfg = _pipeline_accepts_cfg(pipeline_fn) + should_load_config = uses_cfg or pipeline_fn.propagate_config + if should_load_config: + _assert_config_output_path_is_separate( + cfg_path, + output_path, + pipeline_fn=pipeline_fn, + is_root=is_root, + ) + loaded_cfg, raw_cfg = _load_cfg_and_raw( + cfg_path, + overrides, + coerce=is_root, + pipeline_fn=pipeline_fn, + usage="cfg" if uses_cfg else "propagate_config", + ) + cfg = loaded_cfg if uses_cfg else Cfg({}) + if not uses_cfg and pipeline_fn.config_path: + ctx.warnings.append( + f"pipeline {pipeline_fn.name!r} declares config={pipeline_fn.config_path!r} " + "but its function has no `cfg` parameter. The config file is still " + "loaded because propagate_config=True broadcasts it to descendant " + "subpipelines. Add a `cfg` parameter if this pipeline also needs to " + "read the config directly." + ) + else: + if is_root and overrides: + keys = sorted(overrides) + raise CompileError( + f"pipeline {pipeline_fn.name!r} received --override values {keys!r}, " + "but its function has no `cfg` parameter and propagate_config is " + "not enabled. Add a `cfg` parameter to read compile-time config, " + "enable propagate_config=True to broadcast overrides to descendants, " + "or remove the unused override(s)." + ) + if pipeline_fn.config_path: + ctx.warnings.append( + f"pipeline {pipeline_fn.name!r} declares config={pipeline_fn.config_path!r} " + "but its function has no `cfg` parameter, so the config file was " + "not loaded. Remove `config=` when no compile-time config is needed, " + "or add a `cfg` parameter to use it." + ) + cfg = Cfg({}) + raw_cfg = {} + + # 2. Trace + emit. The source dir is on sys.path so any trace-time + # sibling imports inside the body resolve. ``emit_pipeline`` also + # returns the set of argument JSON paths wrapped in ``raw(...)`` — + # legitimate RUNTIME template placeholders the no-template-delimiter + # output guard must skip for THIS artifact's body. + with _temp_sys_path(base_dir): + builder = trace_pipeline(pipeline_fn, cfg=cfg, inputs={}) + body_dict, exempt_paths = emit_pipeline(builder) + + # 3. Compute the canonical compile key for dedup / cycle detection. + key = _compile_key_for(pipeline_fn, cfg_path, overrides) + + # 4. @task local_from_python sidecar for THIS artifact (built, not + # written). Each @task ref is rewritten to a pure + # ``resolve://./.components.yaml#`` URL BEFORE schema + # validation, so the transient ``local-from-python://pending`` + # placeholder never reaches output. + components_entries: dict[str, Any] = {} + components_path: Path | None = None + task_refs = builder.task_refs_for_local_from_python + if task_refs and not ctx.emit_components_sidecar: + task_ids = sorted(tid for tid, _ref in task_refs) + raise CompileError( + "pipeline uses @task component(s) that require a " + "local_from_python resolver sidecar, but " + "emit_components_sidecar=False. Set it to True (the default) to " + f"compile @task pipelines. Offending task(s): {task_ids!r}." + ) + if task_refs: + components_path = output_path.with_name(output_path.stem + ".components.yaml") + components_entries = _build_local_from_python_components(task_refs, components_yaml_dir=components_path.parent) + _rewrite_task_componentref_urls( + body_dict=body_dict, + task_refs=task_refs, + components_yaml_name=components_path.name, + ) + + # 4b. A CHILD artifact is written under ``.subgraphs/``, away from + # its own source directory. Its author-written relative local refs + # (plain ``ref(url="file://./leaf.yaml")``) point at files next to + # the child SOURCE, so relocate them to be relative to the child + # SIDECAR directory — the URL still resolves to the SAME original + # file (no copying), just from the sidecar's location. Compiler- + # managed refs (``@task`` resolver + subpipeline) already point at + # bundle files and are skipped. The root is never relocated: its + # output dir is its bundle root (in-place compile contract). + if not is_root: + _relocate_child_local_refs( + body_dict=body_dict, + builder=builder, + source_dir=base_dir, + sidecar_dir=output_path.parent, + ) + + # 4c. @registered refs point at an EXISTING gen_config.yaml (the + # operation is registered/published elsewhere), so there is no + # sidecar to generate. Rewrite each registered task's + # ``registered://pending`` sentinel to a pure + # ``resolve:///gen_config.yaml#`` URL, computed + # against THIS artifact's output dir so nested subpipeline children + # (written under ``.subgraphs/``) get a correct relative path. + # Runs AFTER 4b relocation so it never touches the relocation pass. + registered_refs = builder.task_refs_for_registered + if registered_refs: + _rewrite_registered_componentref_urls( + body_dict=body_dict, + registered_refs=registered_refs, + artifact_output_dir=output_path.parent, + ) + + artifact = SubgraphArtifact( + key=key, + output_path=output_path, + body=body_dict, + is_root=is_root, + task_count=len(builder.tasks), + components_entries=components_entries or None, + components_path=components_path if components_entries else None, + exempt_paths=exempt_paths, + ) + + # 5. Register the files this artifact will write so asset-policy + # validation accepts refs to them before they exist on disk. + ctx.planned_files.add(output_path.resolve()) + if artifact.components_path is not None: + ctx.planned_files.add(artifact.components_path.resolve()) + + # 6. Compile every nested subpipeline child into its own graph sidecar + # and rewrite this artifact's subpipeline tasks to pure ``file://`` + # refs. This artifact's key is on the active stack while children are + # compiled so a child that reaches back here is detected as a cycle + # (Decision L). Subpipeline children appear only when NM1 recorded + # them; root-only / @task-only compiles skip this entirely. + # + # When ``propagate_config`` is set, push a broadcast layer carrying + # THIS pipeline's OWN config (Decision §4: "broadcast my OWN config"). + # The layer is ``raw_cfg`` overlaid with ``rebroadcast``. ``rebroadcast`` + # is this pipeline's OWN re-broadcastable overrides: + # * ROOT (``rebroadcast_overrides is None``) — its CLI ``--override`` + # values, COERCED with the same ``yaml.safe_load`` coercion + # ``load_cfg(coerce=True)`` applied to the root's own cfg, so the + # broadcast carries the typed value the root itself sees (Finding 2), + # not the raw CLI string. + # * flagged CHILD (``rebroadcast_overrides == {}``) — nothing extra: + # a flagged child broadcasts ONLY its own ``raw_cfg``. The explicit + # ``.override_config`` values set on the edge INTO this child have + # their DEPTH governed by the CALLER's flag (Decision §4 line 122), + # so the caller — not this child — pushes a per-edge layer for them + # (see ``_process_subpipeline_children``). This keeps "nearest + # flagged ancestor that DEFINES the key wins": an outer ancestor's + # value still reaches descendants via the ancestor's OWN layer, which + # remains on the stack. + # Nearest-wins falls out of stack order: an inner layer is iterated last + # in ``_effective_overrides_for_child`` and overwrites outer layers. BOTH + # stacks must pop in ``finally`` so a child that raises CompileError + # leaves them symmetric. + if rebroadcast_overrides is None: + # ROOT: coerce CLI override strings to the YAML type the root's own cfg + # already carries, so the broadcast layer is type-consistent with cfg. + rebroadcast: Mapping[str, Any] = {k: _coerce_override(v) for k, v in overrides.items()} + else: + rebroadcast = rebroadcast_overrides + ctx.active_stack.append(key) + pushed_broadcast = False + if pipeline_fn.propagate_config: + ctx.broadcast_stack.append(BroadcastLayer(config={**raw_cfg, **dict(rebroadcast)})) + pushed_broadcast = True + try: + _process_subpipeline_children(artifact, builder, ctx, parent_propagate_config=pipeline_fn.propagate_config) + finally: + ctx.active_stack.pop() + if pushed_broadcast: + ctx.broadcast_stack.pop() + + return artifact + + +def _process_subpipeline_children( + parent_artifact: SubgraphArtifact, + builder: Any, + ctx: CompileContext, + *, + parent_propagate_config: bool, +) -> None: + """Compile each ``subpipeline(child)(...)`` recorded during ``builder``'s + trace and rewrite the parent task's ``componentRef`` to a pure + ``file://`` URL pointing at the child's graph sidecar. + + For every ``(task_id, SubpipelineRef)`` recorded in + ``builder.task_refs_for_subpipelines``: + + * compute the child :class:`PipelineCompileKey`; + * CYCLE check — if the key is already on ``ctx.active_stack`` raise a + :class:`CompileError` naming the full chain (Decision L); this also + covers self-reference (``A -> A``); + * MAX-DEPTH guard — refuse chains deeper than ``ctx.max_depth``; + * DEDUP — if the key is already compiled (``ctx.registry``) reuse that + child's sidecar; otherwise recurse via :func:`_compile_pipeline_fn` + into ``.subgraphs/-.yaml`` (ALL children + and grandchildren live flat under the ROOT's ``.subgraphs/`` dir); + * rewrite the parent task's ``componentRef`` to a pure ``file://`` URL + relative to the REFERENCING artifact's directory (root→child: + ``./compiled.subgraphs/.yaml``; child→grandchild same dir: + ``./.yaml``), replacing the ``subpipeline://pending`` sentinel. + + Writes nothing — it only mutates ``parent_artifact.body`` and populates + ``ctx.registry`` / ``ctx.planned_files``. + """ + sub_refs: list[tuple[str, SubpipelineRef]] = builder.task_refs_for_subpipelines + if not sub_refs: + return + + parent_tasks = parent_artifact.body.get("implementation", {}).get("graph", {}).get("tasks", {}) + # task_id -> declared child output names, used by the cross-file + # ``taskOutput`` safety net once every child has been compiled. + subpipeline_output_names: dict[str, set[str]] = {} + for task_id, sub_ref in sub_refs: + child_fn = sub_ref.child + child_base_dir = _pipeline_base_dir(child_fn, fallback=ctx.subgraph_dir.parent) + child_cfg_path = _resolve_cfg_path_in_dir(child_fn, child_base_dir) + + # Resolve this child's EFFECTIVE overrides: broadcast from flagged + # ancestors (lenient, nearest-wins) + the explicit ``.override_config`` + # on this edge (strict, wins). When NEITHER a broadcast layer is + # active NOR an explicit override was set, skip the raw-cfg read + # entirely and use ``{}``. The fast path does NOT let a config-taking + # child without a config.yaml compile — that child still raises + # "config file not found" in its own ``_compile_pipeline_fn``. What it + # buys is: one fewer raw-cfg read, and exact preservation of Decision-F + # default isolation (empty effective overrides -> empty + # ``overrides_fingerprint`` -> the SAME dedup key the child gets with no + # feature in play) whenever there is nothing to broadcast or override. + # Ambient PASS-THROUGH context (Fix B / §7): the resolved + # broadcast/override keys this child does NOT declare and therefore + # flow PAST it, unchanged, to its descendants. It is folded into the + # child's compile key so the SAME child reached under two different + # ancestor-broadcast contexts gets distinct keys (and distinct + # sidecars) whenever the difference can affect its descendants — even + # when the child's OWN effective overrides are identical. + ambient_passthrough: dict[str, Any] = {} + if ctx.broadcast_stack or sub_ref.config_overrides: + child_raw = _read_raw_cfg(child_cfg_path) + effective = _effective_overrides_for_child( + child_raw=child_raw, + broadcast_stack=ctx.broadcast_stack, + explicit=sub_ref.config_overrides, + child_name=child_fn.name, + parent_task_id=task_id, + ) + # Resolve the ambient context from the stack AS IT STANDS NOW (the + # per-edge override INTO this child is not pushed yet — correct, + # it is already in this child's ``effective``). The pass-through is + # exactly the resolved keys the child does not declare. + if ctx.broadcast_stack: + resolved_ambient = _resolve_ambient_context(ctx.broadcast_stack) + ambient_passthrough = {k: v for k, v in resolved_ambient.items() if k not in child_raw} + else: + effective = {} + + child_key = _compile_key_for( + child_fn, + child_cfg_path, + effective, + fingerprint_context=f"on subpipeline task {task_id!r} -> child pipeline {child_fn.name!r}", + ambient_context=ambient_passthrough or None, + ) + + # Cycle detection (covers self-reference). active_stack holds the + # chain from the root down to (and including) the artifact whose + # children we are compiling. + if child_key in ctx.active_stack: + chain = [*ctx.active_stack, child_key] + chain_str = " -> ".join(k.display() for k in chain) + raise CompileError( + f"nested pipeline cycle detected: {chain_str}. Recursive " + "Python pipelines are not supported; break the cycle or use a " + "published component boundary." + ) + + child_artifact = ctx.registry.get(child_key) + if child_artifact is None: + # Max-depth guard: the chain TO the child would be one deeper + # than the current stack. + if len(ctx.active_stack) + 1 > ctx.max_depth: + chain = [*ctx.active_stack, child_key] + chain_str = " -> ".join(k.display() for k in chain) + raise CompileError( + f"nested pipeline max depth {ctx.max_depth} exceeded: " + f"{chain_str}. Reduce nesting depth or restructure the " + "pipeline graph." + ) + slug = _slugify(child_fn.name) + child_output_path = ctx.subgraph_dir / f"{slug}-{child_key.hash8()}.yaml" + # Per-edge explicit-override depth (Decision §4 line 122-124): an + # explicit ``.override_config`` set on THIS edge flows deep iff the + # CALLER (this parent) is flagged. When it is, push a broadcast + # layer carrying JUST this edge's explicit overrides as the INNERMOST + # (nearest) layer while the child's subtree compiles, then pop it. + # This makes the explicit override (a) win over the parent's own + # broadcast for the child's DESCENDANTS (explicit always wins, and + # nearest-wins puts it last), and (b) reach descendants that declare + # the key (lenient). The child ITSELF already got the explicit + # override strictly via ``effective``; this per-edge layer is pushed + # AROUND the recursion only, so it affects the child's DESCENDANTS, + # not the child's own ``effective`` / ``child_key`` (computed above). + # The flagged-child re-broadcast passes ``{}`` — a flagged child + # broadcasts only its OWN config; this edge's explicit override + # depth belongs to the caller and is handled by THIS layer. + pushed_edge_override = False + if parent_propagate_config and sub_ref.config_overrides: + # ``explicit=True`` puts this layer in the explicit tier, which + # is resolved AFTER the whole broadcast tier in + # ``_effective_overrides_for_child`` — so this edge's override + # outranks even a NEARER flagged descendant's own-config + # broadcast of the same key (PROPAGATE_CONFIG_DESIGN §4). + ctx.broadcast_stack.append(BroadcastLayer(config=dict(sub_ref.config_overrides), explicit=True)) + pushed_edge_override = True + try: + child_artifact = _compile_pipeline_fn( + child_fn, + child_output_path, + ctx, + effective, + is_root=False, + base_dir=child_base_dir, + rebroadcast_overrides={}, + ) + finally: + if pushed_edge_override: + ctx.broadcast_stack.pop() + ctx.registry[child_key] = child_artifact + parent_artifact.children.append(child_artifact) + else: + # Reused (dedup / diamond) — still a child of this parent for + # structural completeness, but compiled only once. + if child_artifact not in parent_artifact.children: + parent_artifact.children.append(child_artifact) + + # Rewrite this task's componentRef to a pure file:// URL relative to + # the REFERENCING artifact's directory. + rel = _relpath_posix(child_artifact.output_path, parent_artifact.output_path.parent) + if task_id not in parent_tasks: + raise CompileError( + f"subpipeline ref recorded for task {task_id!r} but no such " + "task in the emitted body (internal error)." + ) + parent_tasks[task_id]["componentRef"] = {"url": f"file://{rel}"} + + # INPUT interface validation (Decision E). The compiled child body + # is the source of truth for declared inputs; ``wait_for`` / + # ``depends_on`` are NOT special — they must be declared child + # In[...] inputs if passed. + _validate_subpipeline_inputs( + parent_task_id=task_id, + parent_args=parent_tasks[task_id].get("arguments", {}), + child_artifact=child_artifact, + ) + subpipeline_output_names[task_id] = _child_output_names(child_artifact.body) + + # OUTPUT cross-file validation (Decision E / K) — a safety net beyond the + # strict SubpipelineOutputProxy: every ``taskOutput`` in the parent body + # that targets a subpipeline task must name an output the child declares. + _validate_subpipeline_output_refs(parent_artifact.body, subpipeline_output_names) + + +def _child_input_specs(child_body: dict[str, Any]) -> tuple[list[str], set[str]]: + """Return ``(declared_input_names, required_input_names)`` from a child's + compiled body ``inputs`` block. + + An input is REQUIRED unless it is marked ``optional`` (the tracer sets + ``optional: true`` for any ``In[T]`` parameter that has a default). + Declared names preserve declaration order for stable error messages. + """ + inputs = child_body.get("inputs", []) or [] + names: list[str] = [] + required: set[str] = set() + for spec in inputs: + if not isinstance(spec, dict): + continue + name = spec.get("name") + if name is None: + continue + names.append(name) + if not spec.get("optional"): + required.add(name) + return names, required + + +def _child_output_names(child_body: dict[str, Any]) -> set[str]: + """Return the set of output names declared by a child's compiled body.""" + outs = child_body.get("outputs", []) or [] + return {name for o in outs if isinstance(o, dict) and isinstance(name := o.get("name"), str)} + + +def _validate_subpipeline_inputs( + *, + parent_task_id: str, + parent_args: dict[str, Any], + child_artifact: SubgraphArtifact, +) -> None: + """Validate a subpipeline call's arguments against the child interface. + + Rejects an UNKNOWN argument name (not a declared child input) and a + MISSING REQUIRED child input (declared, non-optional, not supplied). + Omitted optional/default child inputs are allowed. Raises a clear + :class:`CompileError` BEFORE any file is written (Decision E). + """ + declared, required = _child_input_specs(child_artifact.body) + child_name = child_artifact.key.pipeline_name + arg_names = set(parent_args.keys()) + + unknown = sorted(arg_names - set(declared)) + if unknown: + raise CompileError( + f"subpipeline task {parent_task_id!r} passes unknown input " + f"{unknown[0]!r} to child pipeline {child_name!r}. Declared child " + f"inputs: {declared}." + ) + + missing = sorted(required - arg_names) + if missing: + raise CompileError( + f"subpipeline task {parent_task_id!r} calls child pipeline " + f"{child_name!r} without required input {missing[0]!r}. Pass " + f"{missing[0]}=... or give the child In[...] parameter a default." + ) + + +def _validate_subpipeline_output_refs( + body: dict[str, Any], + subpipeline_output_names: dict[str, set[str]], +) -> None: + """Assert every ``taskOutput`` targeting a subpipeline task names a + declared child output. + + Scans both task ``arguments`` and graph ``outputValues``. This is the + compiler-owned cross-file safety net that protects the serialized YAML + even if a proxy bug let an undeclared output slip through (Decision K). + """ + if not subpipeline_output_names: + return + graph = body.get("implementation", {}).get("graph", {}) + tasks = graph.get("tasks", {}) if isinstance(graph, dict) else {} + + def _check(value: Any, loc: str) -> None: + if not isinstance(value, dict): + return + task_output = value.get("taskOutput") + if not isinstance(task_output, dict): + return + target = task_output.get("taskId") + if target not in subpipeline_output_names: + return + out_name = task_output.get("outputName") + declared = subpipeline_output_names[target] + if out_name not in declared: + raise CompileError( + f"{loc} references output {out_name!r} of subpipeline task " + f"{target!r}, but that child pipeline declares only " + f"{sorted(declared)}." + ) + + if isinstance(tasks, dict): + for task_id, task in tasks.items(): + if not isinstance(task, dict): + continue + arguments = task.get("arguments") + if isinstance(arguments, dict): + for arg_name, value in arguments.items(): + _check(value, f"task {task_id!r} argument {arg_name!r}") + output_values = graph.get("outputValues") if isinstance(graph, dict) else None + if isinstance(output_values, dict): + for out_key, value in output_values.items(): + _check(value, f"outputValues key {out_key!r}") + + +def _pipeline_base_dir(pipeline_fn: PipelineFn, *, fallback: Path) -> Path: + """Resolve the source directory a child pipeline's cfg + sibling imports + are relative to. + + Prefers the ``@pipeline`` decorator's captured ``caller_dir`` (the + child's own source file directory — Decision F), then the decorated + function's source file, then ``fallback`` (the root bundle directory) + for dynamically built pipelines with no on-disk source. + """ + if pipeline_fn.caller_dir is not None: + return pipeline_fn.caller_dir + src = _pipeline_source_path(pipeline_fn) + if src is not None: + return src.parent + return fallback + + +def _slugify(name: str) -> str: + """Slugify a pipeline display name for a child sidecar filename. + + Lowercases, replaces any run of non-alphanumeric characters with a + single hyphen, and trims leading/trailing hyphens (``"Judge Options"`` + -> ``"judge-options"``). Collisions between two children that slug to + the same name are disambiguated by the ``hash8`` suffix. + """ + slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") + return slug or "pipeline" + + +def _compile_key_for( + pipeline_fn: PipelineFn, + cfg_path: Path, + overrides: Mapping[str, Any], + *, + fingerprint_context: str | None = None, + ambient_context: Mapping[str, Any] | None = None, +) -> PipelineCompileKey: + """Build the canonical :class:`PipelineCompileKey` for ``pipeline_fn``. + + Paths are canonicalised per Decision B (repo-relative POSIX inside a git + repo, else resolved absolute POSIX). A dynamically built pipeline whose + source cannot be located falls back to a qualname-derived sentinel so it + still produces a stable, distinct key. + + ``fingerprint_context`` is a human label naming the affected pipeline / + child edge; it is woven into the :func:`overrides_fingerprint` error if an + override value is not a compile-time constant, so a non-serializable + ``.override_config`` value surfaces as an actionable :class:`CompileError` + rather than a bare ``TypeError``. + + ``ambient_context`` is the AMBIENT PASS-THROUGH context (Fix B / §7): the + active broadcast/override keys this node does NOT declare and therefore + flow PAST it to its descendants. Folding it into the fingerprint keeps the + same node distinct when it is reached under two different ancestor-broadcast + contexts that its descendants would resolve differently. + + Fingerprint encoding (collision-proof, byte-stable): + + * **Empty / ``None`` ambient** (always true for the default-isolation + compile) -> ``overrides_fingerprint(overrides)`` UNCHANGED, byte-for-byte + identical to before Fix B. + * **Non-empty ambient** -> a small envelope keyed by reserved sentinels + (``"\\x00effective"`` / ``"\\x00ambient"``) that cannot collide with real + config keys, routed through :func:`overrides_fingerprint` so its + non-serializable-value -> :class:`CompileError` detection (which recurses + into dicts) still fires for BOTH effective and ambient values. + """ + src = _pipeline_source_path(pipeline_fn) + if src is not None: + source_canon = canonical_repo_path(src) + else: + source_canon = f"" + if ambient_context: + # Reserved keys (NUL-prefixed) can never be real config keys, so an + # envelope never collides with an effective-only fingerprint over the + # same keys (e.g. effective={x:1},ambient={} vs effective={x:1},ambient={y:2}). + fingerprint_input: Mapping[str, Any] = { + "\x00effective": dict(sorted(overrides.items())), + "\x00ambient": dict(sorted(ambient_context.items())), + } + else: + # Byte-identical to today's key: the default-isolation guarantee (§7). + fingerprint_input = overrides + return PipelineCompileKey( + source_path=source_canon, + function_qualname=pipeline_fn.fn.__qualname__, + pipeline_name=pipeline_fn.name, + config_path=canonical_repo_path(cfg_path), + overrides_fingerprint=overrides_fingerprint(fingerprint_input, context=fingerprint_context), + ) + + +def _validate_artifact(artifact: SubgraphArtifact, ctx: CompileContext) -> None: + """Validate one planned artifact in memory (no writes). + + Runs a leftover-sentinel scan (a missed @task / subpipeline rewrite), + ``validate_dehydrated_pipeline`` on the body, a dump/reload + re-validation (to catch dumper issues), and the relative-local-ref asset + policy. The dumped text is cached on the artifact so the write pass + emits exactly the bytes that were validated. + """ + body = artifact.body + label = None if artifact.is_root else _artifact_label(artifact) + + # No pending compiler sentinel may survive into a written artifact. A + # surviving sentinel means a missed @task or subpipeline rewrite — fail + # with a targeted internal error BEFORE schema validation so the cause + # is obvious, and write nothing. + _assert_no_pending_sentinels(body, label) + + # ``raw(...)`` argument paths whose template delimiters are legitimate + # RUNTIME placeholders the no-template-delimiter output guard must skip + # for THIS artifact's body. Empty unless the artifact used ``raw(...)``. + exempt_paths = artifact.exempt_paths + + try: + validate_dehydrated_pipeline(body, exempt_paths) + except SchemaValidationError as e: + if label is None: + raise CompileError(str(e)) from e + raise CompileError(f"{label}: {e}") from e + + dumped = dump_yaml(body, sort_keys=False) + reloaded = yaml.safe_load(dumped) + try: + validate_dehydrated_pipeline(reloaded, exempt_paths) + except SchemaValidationError as e: + prefix = "" if label is None else f"{label}: " + raise CompileError(f"{prefix}compiled YAML failed re-validation after dump/reload: {e}") from e + + # Asset policy (Decision J, extended to children in Phase 8). EVERY + # artifact's relative local refs are validated relative to THAT + # artifact's own output directory: + # * the ROOT body relative to the root output dir (``label`` is None, + # preserving the verbatim single-pipeline error message); + # * each CHILD body relative to ITS child-sidecar dir (``label`` adds + # child-sidecar + task context to the error). + # Generated bundle files the compiler is about to write — child graph + # sidecars and child ``@task`` components sidecars — are in + # ``ctx.planned_files`` and count as present, so the compiler-managed + # parent→child / child→child / child @task refs pass. A child's + # author-written relative leaf ref was relocated (NM2) to be relative to + # the child-sidecar dir, so it is validated against the real source-side + # file via ``../``; a missing external leaf fails clearly here, before + # any file is written. + _validate_local_component_refs_for_artifact( + body, + artifact.output_path.parent, + ctx.planned_files, + artifact_label=label, + ) + artifact.dumped_text = dumped + + +# Pending componentRef sentinels stamped during trace/emit. All MUST be +# rewritten before validation; a survivor is an internal compiler bug. +_PENDING_SENTINELS = ( + _SUBPIPELINE_URL_PLACEHOLDER, + _TASK_URL_PLACEHOLDER, + _REGISTERED_URL_PLACEHOLDER, +) + + +def _assert_no_pending_sentinels(body_dict: dict[str, Any], artifact_label: str | None) -> None: + """Raise if any task ``componentRef.url`` still holds a pending compiler + sentinel (``subpipeline://pending`` / ``local-from-python://pending`` / + ``registered://pending``). + + These are stamped during trace/emit and rewritten to pure refs by the + compile driver; a survivor means a rewrite was missed. Fail clearly with + task context so the bug is obvious, and (because this runs before the + write pass) leave nothing on disk. + """ + where = artifact_label or "root pipeline" + tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_id, task in tasks.items(): + if not isinstance(task, dict): + continue + cref = task.get("componentRef") + if not isinstance(cref, dict): + continue + url = cref.get("url") + if isinstance(url, str) and url in _PENDING_SENTINELS: + raise CompileError( + f"internal error: {where} task {task_id!r} still carries the " + f"unresolved compiler sentinel {url!r} in its componentRef " + "(a missed @task / subpipeline rewrite). No output was written." + ) + + +def _write_artifact(artifact: SubgraphArtifact) -> None: + """Write a validated artifact (body YAML + optional @task sidecar). + + Only called after the WHOLE bundle has validated, so no partial bundle + is ever left on disk. + """ + assert artifact.dumped_text is not None # set by _validate_artifact + artifact.output_path.parent.mkdir(parents=True, exist_ok=True) + artifact.output_path.write_text(artifact.dumped_text) + if artifact.components_entries: + assert artifact.components_path is not None # narrow for type-checkers + artifact.components_path.write_text(dump_yaml(artifact.components_entries, sort_keys=False)) + + +def _artifact_label(artifact: SubgraphArtifact) -> str: + """Short ``child pipeline '' ()`` label for + error messages on a non-root artifact.""" + return f"child pipeline {artifact.key.pipeline_name!r} " f"({artifact.output_path.name})" + + +# --------------------------------------------------------------------------- +# @task local_from_python sidecar helpers +# +# These build the +# ``.components.yaml`` resolver config for @task-derived refs and +# rewrite each task's componentRef to a pure ``resolve://`` URL. Paths are +# POSIX and relative to the sidecar directory so the compiled bundle is +# portable (the sidecar and the files it points at can move together). + + +def _fragment_for_task(ref: CallableRef) -> str: + """Stable fragment key for a @task ref in the components sidecar. + + Uses the hyphenated function name, matching tangle-deploy's own + ``_resolve_local_from_python`` output-filename convention + (``my_task`` -> ``my-task``). Multiple call sites for the SAME + function share one fragment — the local_from_python entry is keyed by + source file, not by call site. + """ + assert ref._task_function_name is not None # only @task refs reach here + return ref._task_function_name.replace("_", "-") + + +def _relpath_posix(target: Path, start: Path) -> str: + """``os.path.relpath`` but always POSIX-style for YAML stability. + + Raises: + CompileError: when no relative path can be formed (e.g. ``target`` + and ``start`` are on different drives on Windows). + """ + try: + rel = os.path.relpath(str(target), str(start)) + except ValueError as e: + raise CompileError( + f"cannot form a relative path from {start} to {target}: {e}. " + "Compile into the pipeline source directory, or reference the " + "component with an absolute file://, gs://, or resolve:// URL." + ) from e + # Normalise to POSIX separators so the sidecar diffs are stable. + rel = rel.replace(os.sep, "/") + # Prefix bare relative paths with ``./`` to match the + # local_from_python convention. + if not rel.startswith(".") and not rel.startswith("/"): + rel = "./" + rel + return rel + + +# ---------------------------------------------------------------------------- +# @registered URL rewrite. Unlike @task (which builds a sidecar), @registered +# references an EXISTING gen_config.yaml: the driver only resolves that config +# and rewrites each registered task's ``registered://pending`` sentinel to a +# pure ``resolve:///gen_config.yaml#`` URL. Resolution +# touches the filesystem (marker walk, nearest-gen_config walk, relpath) and so +# happens lazily here at compile time, never at decoration/import time. + + +def _fragment_for_registered(ref: CallableRef) -> str: + """Fragment key for a @registered ref's ``resolve://`` URL. + + Per decision 2, the author-supplied ``fragment`` is used VERBATIM as the + top-level key in ``gen_config.yaml``. When omitted, it defaults to the + function name VERBATIM (no hyphenation, unlike :func:`_fragment_for_task`) + — gen_config fragments are hand-authored keys, not generated filenames, so + we must not mangle them. + """ + fragment = ref._registered_fragment or ref._registered_function_name + assert fragment is not None # only @registered refs reach here + return fragment + + +# Zone-root marker filenames — the extension seam for distributions that +# carry a zone concept. An explicit relative ``@registered(gen_config=...)`` +# path is resolved against the nearest ancestor directory that contains one +# of these marker files (see :func:`_find_zone_root`). +# +# EMPTY by default: the open-source ``tangle`` CLI has no zone concept, so an +# explicit relative gen_config path is unsupported until a downstream +# distribution registers a marker. A distribution that DOES carry a zone +# concept appends its own marker filename at import time to restore zone-root +# resolution. This mirrors the mutable module-level registry seams +# elsewhere in the CLI (e.g. the hydrator's ``COMPONENT_RESOLVERS``): mutate +# the list in place rather than rebinding it, so callers that already imported +# the name observe the addition. +ZONE_ROOT_MARKERS: list[str] = [] + + +def _find_zone_root(source: Path) -> Path | None: + """Return the nearest ancestor dir of ``source`` holding a zone-root + marker (see :data:`ZONE_ROOT_MARKERS`), or ``None`` if none exists. + + An explicit ``@registered(gen_config="rel/path")`` is resolved relative to + this directory (decision 1) so authors write the gen_config path the same + way the distribution's existing ``resolve://`` references do. When no + markers are registered (the default open-source build) this always returns + ``None`` and explicit relative gen_config paths are rejected with an + actionable error. + """ + if not ZONE_ROOT_MARKERS: + return None + for ancestor in source.parents: + if any((ancestor / marker).is_file() for marker in ZONE_ROOT_MARKERS): + return ancestor + return None + + +def _find_nearest_gen_config(source: Path) -> Path | None: + """Return the nearest ancestor ``gen_config.yaml`` of ``source``, or + ``None`` if none exists. + + Used as the default when ``@registered`` is given no explicit + ``gen_config=`` (decision 1): the operation file is assumed to live under + (or beside) the gen_config.yaml it is registered in. + """ + for ancestor in source.parents: + candidate = ancestor / "gen_config.yaml" + if candidate.is_file(): + return candidate + return None + + +def _resolve_registered_gen_config(ref: CallableRef) -> Path | str: + """Resolve a @registered ref's gen_config to a value :func:`_rewrite_registered_componentref_urls` emits. + + Return contract (this is what ``_rewrite`` keys off of): + + - a returned ``str`` is emitted VERBATIM after the ``resolve://`` scheme, + with NO relpath. Two cases produce a ``str``: + * a genuinely remote scheme (``gs://`` / ``http://`` / ``https://``) — + the hydrator fetches it directly and it cannot be existence-checked + at compile time; + * a validated absolute LOCAL path — the resolved, on-disk-checked + ``os.fspath(...)`` of a ``file://`` URL or an absolute filesystem + path. Emitted as ``resolve:///abs/x.yaml#frag``, which the hydrator + resolves via ``Path("/abs/x.yaml")``. + - a returned ``Path`` is relpath'd against the artifact output dir by + ``_rewrite`` (the relative/marker and omitted/nearest cases). + + Resolution rules (decision 1): + + - ``gs://`` / ``http(s)://`` -> returned VERBATIM as a ``str``. + - ``file://...`` -> the ``file://`` prefix is STRIPPED and the remainder + treated as a LOCAL path. WHY strip: the hydrator's ``resolve://`` parser + does NOT understand a nested ``file://`` (it does ``Path(file_path)`` + with no scheme stripping and special-cases ``gs://`` only), so a + ``file://``-wrapped payload like ``resolve://file:///abs/x.yaml`` fails + to hydrate — ``Path("file:///abs/x.yaml")`` is mis-parsed as a relative + segment. Stripping at compile time makes documented ``file://`` usage + actually resolvable. A non-absolute remainder is resolved against the op + source file's directory. The path is then ``.resolve()``-d, + existence-checked, and returned as ``os.fspath(...)`` (a ``str``). + - an absolute filesystem path (no scheme) -> ``.resolve()``-d, + existence-checked, returned as ``os.fspath(...)`` (a ``str``). + - explicit relative ``gen_config=`` -> resolved against the zone root + (the nearest ancestor holding a registered :data:`ZONE_ROOT_MARKERS` + marker), existence-checked, returned as a ``Path``. + - omitted ``gen_config`` -> the nearest ancestor ``gen_config.yaml``, + returned as a ``Path``. + + Raises: + CompileError: when an explicit relative path has no zone-root marker + above the source; when an omitted path finds no ancestor + gen_config.yaml; when an explicit relative path resolves to a + non-existent file; or when a ``file://`` URL / absolute path + resolves to a path that does not exist on disk. + """ + raw = ref._registered_gen_config + source = ref._registered_source_path + assert source is not None # only @registered refs reach here + + # Genuinely remote schemes -> used verbatim; the hydrator fetches them + # directly and they can't be existence-checked at compile time. + if raw is not None and (raw.startswith("gs://") or raw.startswith("http://") or raw.startswith("https://")): + return raw + + # file:// URL or absolute filesystem path -> a validated absolute LOCAL + # path, emitted verbatim (no relpath). The file:// scheme is stripped here + # because the hydrator's resolve:// parser doesn't understand it. + if raw is not None and (raw.startswith("file://") or os.path.isabs(raw)): + if raw.startswith("file://"): + # Mirror the hydrator's own file:// stripping + # (pipeline_hydrator.py: ``url[len("file://"):]``). + local = raw[len("file://") :] + local_path = Path(local) + # A non-absolute remainder (e.g. ``file://rel/x.yaml``) is resolved + # against the op source file's directory. + if not local_path.is_absolute(): + local_path = source.parent / local_path + else: + local_path = Path(raw) + resolved = local_path.resolve() + if not resolved.exists(): + raise CompileError( + f"@registered(gen_config={raw!r}) resolves to {resolved}, which does not " "exist on disk." + ) + return os.fspath(resolved) + + if raw is not None: + # Explicit relative path -> marker-relative (zone root). + root = _find_zone_root(source) + if root is None: + if ZONE_ROOT_MARKERS: + markers = " / ".join(repr(m) for m in ZONE_ROOT_MARKERS) + raise CompileError( + f"@registered(gen_config={raw!r}) is a relative path but no " + f"zone-root marker ({markers}) was found in any ancestor " + f"directory of {source}. Add the marker at the zone root, or " + "pass an absolute / gs:// gen_config path instead." + ) + raise CompileError( + f"@registered(gen_config={raw!r}) is a relative path, but this " + "build has no zone-root markers registered, so a zone root " + f"cannot be located for {source}. Pass an absolute / file:// / " + "gs:// gen_config path instead, or omit gen_config to use the " + "nearest ancestor 'gen_config.yaml'." + ) + resolved = (root / raw).resolve() + else: + # Omitted -> nearest ancestor gen_config.yaml. + nearest = _find_nearest_gen_config(source) + if nearest is None: + raise CompileError( + "@registered could not find a 'gen_config.yaml' in any ancestor " + f"directory of {source}. Pass gen_config=... explicitly (relative " + "to the zone-root marker, or an absolute / gs:// path)." + ) + resolved = nearest + + if not resolved.exists(): + raise CompileError( + f"@registered gen_config not found on disk: {resolved}. Check the " + "path and the zone-root marker location." + ) + return resolved + + +def _rewrite_registered_componentref_urls( + *, + body_dict: dict[str, Any], + registered_refs: list[tuple[str, CallableRef]], + artifact_output_dir: Path, +) -> None: + """Rewrite each @registered task's ``componentRef`` to a pure resolve URL. + + Mutates ``body_dict`` in place. Each entry in ``registered_refs`` is a + ``(task_id, CallableRef)`` tuple; the task_id is the key under + ``implementation.graph.tasks`` whose componentRef is replaced with + ``{"url": "resolve:///gen_config.yaml#"}``. + + The relative path is computed against ``artifact_output_dir`` (this + artifact's own output directory) so nested subpipeline children, written + under ``.subgraphs/``, get a correct relative path. The emission + form depends on what :func:`_resolve_registered_gen_config` returns: + + - ``gs://`` / ``http(s)://`` -> emitted verbatim after ``resolve://`` + (genuinely remote, no relpath). + - a ``file://`` URL or an absolute path -> resolved to a validated + absolute LOCAL path (existence-checked at compile time) and emitted as + an absolute ``resolve://`` URL — the ``file://`` scheme is stripped at + resolve time so the hydrator's ``resolve://`` parser can read it. + - relative / marker / omitted -> relpath'd against ``artifact_output_dir``. + """ + tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_id, ref in registered_refs: + target = _resolve_registered_gen_config(ref) + fragment = _fragment_for_registered(ref) + if isinstance(target, str): + # Remote / absolute override -> used verbatim, no relpath. + url = f"resolve://{target}#{fragment}" + else: + url = f"resolve://{_relpath_posix(target, artifact_output_dir)}#{fragment}" + if task_id not in tasks: + raise CompileError( + f"@registered ref recorded for task {task_id!r} but no such task " + "in the emitted body (internal error)." + ) + tasks[task_id]["componentRef"] = {"url": url} + + +def _relocate_child_local_refs( + *, + body_dict: dict[str, Any], + builder: Any, + source_dir: Path, + sidecar_dir: Path, +) -> None: + """Rewrite a child's author-written relative local componentRefs from + being relative to its SOURCE dir to relative to its SIDECAR dir. + + A child compiles into ``.subgraphs/`` but its ``ref(url=...)`` + URLs were authored relative to the child's own source file. Rewriting + them keeps each ref pointing at the SAME original file (no copying) so + the existing hydrator — which resolves child refs relative to the + loaded sidecar's directory — still finds it. + + Skips compiler-managed tasks (``@task`` resolver sidecars, nested + subpipeline refs, and ``@registered`` refs): those are rewritten by + their own driver passes to URLs already computed against the sidecar / + output directory, not source-relative files. Every relative form (bare + ``file://x.yaml``, ``./`` and ``../``) is relocated; only absolute / + remote URLs (``file:///``, ``gs://``, ``http(s)://``) are left + untouched. + """ + managed: set[str] = {tid for tid, _ref in builder.task_refs_for_local_from_python} + managed |= {tid for tid, _ref in builder.task_refs_for_subpipelines} + managed |= {tid for tid, _ref in builder.task_refs_for_registered} + tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_id, task in tasks.items(): + if task_id in managed or not isinstance(task, dict): + continue + cref = task.get("componentRef") + if not isinstance(cref, dict): + continue + url = cref.get("url") + if not isinstance(url, str): + continue + relocated = _relocate_relative_local_url(url, source_dir, sidecar_dir) + if relocated is not None: + cref["url"] = relocated + + +def _relocate_relative_local_url(url: str, source_dir: Path, sidecar_dir: Path) -> str | None: + """Return ``url`` rewritten relative to ``sidecar_dir`` instead of + ``source_dir``, preserving the scheme and any ``#fragment``. + + EVERY RELATIVE local ``file://``/``resolve://`` URL is relocated -- + bare (``file://child.yaml``), ``./`` (``file://./child.yaml``) and + ``../`` (``file://../child.yaml``) forms alike -- because the hydrator + resolves ANY non-absolute ``file://``/``resolve://`` path relative to + the loaded YAML's directory (see + ``PipelineHydrator._fetch_component_from_file_url``). Returns ``None`` + (leave as-is) for absolute (``file:///abs``) or remote URLs the + compiler must not touch, for the empty path, AND for payloads that are + themselves a remote/absolute URL (``resolve://gs://…``, + ``resolve://https://…``, ``file://gs://…``) — those are not relative + local paths and must reach the hydrator as-authored rather than being + mangled into a bogus local path (the hydrator supports ``gs://`` + resolve configs; unsupported nested-remote refs surface their own + clear error there). + """ + for scheme in ("file://", "resolve://"): + if not url.startswith(scheme): + continue + rest = url[len(scheme) :] + path_part, sep, fragment = rest.partition("#") + # Skip ABSOLUTE local refs (``file:///abs`` -> ``path_part`` starts + # with ``/``), the empty path, AND nested remote/absolute payloads + # (``resolve://gs://…``, ``file://gs://…``, ``resolve://https://…``). + # A nested URL always contains ``://``; a relative local path never + # does, so ``"://" in path_part`` cleanly discriminates the two. + # (A scheme-looking relative path like ``file://foo://bar.yaml`` is + # also skipped — an intentional, acceptable tradeoff: such ambiguous + # scheme-looking local paths are unsupported, and skipping is + # preferred over a brittle hydrator-scheme allowlist.) + if not path_part or path_part.startswith("/") or "://" in path_part: + return None + target = (source_dir / path_part).resolve() + new_rel = _relpath_posix(target, sidecar_dir) + return f"{scheme}{new_rel}#{fragment}" if sep else f"{scheme}{new_rel}" + return None + + +def _build_local_from_python_components( + task_refs: list[tuple[str, CallableRef]], *, components_yaml_dir: Path +) -> dict[str, Any]: + """Build the ``.components.yaml`` content for @task refs. + + Returns an ordered map ``{fragment: {name?, local_from_python: + {image?, function, dependencies_from?, file}}}``, DEDUPED by FUNCTION + (the fragment = hyphenated function name). The SAME @task function + called from multiple task sites collapses to one entry; TWO DISTINCT + @task functions defined in ONE file each get their own entry (they + share the same ``file:`` but carry different ``function:`` keys and + distinct fragments). This matches how ``_rewrite_task_componentref_urls`` + points each task at its OWN function fragment — deduping by source path + instead would drop every function but the first and leave the others' + ``resolve://...#`` refs dangling. + + The ``function`` field is always emitted so hydrate's + ``regenerate_yaml`` extracts the right function: it otherwise defaults + to the file STEM, which is wrong whenever the @task function name + differs from the source filename (the common case). + + Paths in ``local_from_python.{file,dependencies_from}`` are POSIX and + relative to ``components_yaml_dir`` so the sidecar is portable: as + long as the layout under that directory matches at compile- and + hydrate-time, the paths resolve correctly. + + Raises: + CompileError: when two distinct source files map to the same + fragment (function-name collision), when a referenced local + file (the @task source or its ``dependencies_from``) is + unreachable, or when a relative path cannot be formed (see + :func:`_relpath_posix`). + """ + seen_fragments: dict[str, Path] = {} + entries: dict[str, Any] = {} + for _task_id, ref in task_refs: + source = ref._task_source_path + if source is None: # defensive — only @task refs are recorded here + continue + fragment = _fragment_for_task(ref) + + prior_source = seen_fragments.get(fragment) + if prior_source is not None: + # Already emitted this fragment. Fine when it is the SAME source + # (the same @task called from multiple sites). A DIFFERENT + # source sharing the function name would silently collide on the + # resolve:// fragment, so reject it loudly. + if prior_source != source: + raise CompileError( + "two distinct @task source files map to the same sidecar " + f"fragment {fragment!r}: {prior_source} and {source}. " + "Rename one of the @task functions so each has a unique " + "name (the function name becomes the resolve:// fragment)." + ) + continue + + if not source.exists(): + raise CompileError( + f"@task source file is unreachable: {source}. Compile into " + "the pipeline source directory, or reference the component " + "with an absolute file://, gs://, or resolve:// URL." + ) + + local_from_python: dict[str, Any] = {} + if ref._task_image is not None: + local_from_python["image"] = ref._task_image + # Always pin the function name. Without it the hydrator defaults + # to the file stem and extracts the wrong symbol. + assert ref._task_function_name is not None + local_from_python["function"] = ref._task_function_name + if ref._task_dependencies_from is not None: + deps = ref._task_dependencies_from + if not deps.exists(): + raise CompileError( + f"@task dependencies_from file is unreachable: {deps}. " + "Point dependencies_from at an existing file or drop it." + ) + local_from_python["dependencies_from"] = _relpath_posix(deps, components_yaml_dir) + local_from_python["file"] = _relpath_posix(source, components_yaml_dir) + + seen_fragments[fragment] = source + # The component name is NOT emitted here. A top-level ``name`` on a + # resolve entry means "resolve a published component by this name" to + # the hydrator (PipelineHydrator._resolve_primary), which would let a + # same-named library component silently win over this local @task. The + # component's name comes from its source docstring (``Metadata: Name:``) + # at hydrate time, read by regenerate_yaml. + entry: dict[str, Any] = {"local_from_python": local_from_python} + entries[fragment] = entry + return entries + + +def _rewrite_task_componentref_urls( + *, + body_dict: dict[str, Any], + task_refs: list[tuple[str, CallableRef]], + components_yaml_name: str, +) -> None: + """Rewrite each @task task's ``componentRef`` to a pure resolve URL. + + Mutates ``body_dict`` in place. Each entry in ``task_refs`` is a + ``(task_id, CallableRef)`` tuple; the task_id is the key under + ``implementation.graph.tasks`` whose componentRef is replaced with + ``{"url": "resolve://./#"}``. + """ + tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_id, ref in task_refs: + fragment = _fragment_for_task(ref) + url = f"resolve://./{components_yaml_name}#{fragment}" + if task_id not in tasks: + raise CompileError( + f"@task ref recorded for task {task_id!r} but no such task in " "the emitted body (internal error)." + ) + tasks[task_id]["componentRef"] = {"url": url} + + +def _relative_local_ref_target(url: str) -> str | None: + """Return the relative path portion of a RELATIVE local componentRef + URL, stripped of any ``#fragment``. + + Matches EVERY relative ``file://``/``resolve://`` form -- bare + (``file://child.yaml``), ``file://./``, ``file://../`` and the + ``resolve://`` equivalents -- because the hydrator resolves ANY + non-absolute path relative to the compiled YAML's directory. Returns + ``None`` for absolute (``file:///``), remote (``gs://``, + ``http(s)://``), empty, payloads that are themselves a remote/absolute + URL (``resolve://gs://…``, ``resolve://https://…``, ``file://gs://…``), + or otherwise non-relative URLs — those are the user's responsibility + and are not checked at compile time (the hydrator handles supported + nested-remote configs like ``resolve://gs://…`` and surfaces its own + clear error for unsupported ones). + """ + for scheme in ("file://", "resolve://"): + if url.startswith(scheme): + rest = url[len(scheme) :].split("#", 1)[0] + # Skip absolute local refs (``file:///abs``), the empty path, + # AND nested remote/absolute payloads (``resolve://gs://…``, + # ``file://gs://…``, ``resolve://https://…``). A nested URL + # always contains ``://``; a relative local path never does, so + # ``"://" in rest`` cleanly discriminates the two. (A + # scheme-looking relative path like ``file://foo://bar.yaml`` is + # also skipped — an intentional, acceptable tradeoff over a + # brittle hydrator-scheme allowlist.) Treat every remaining + # relative form (bare, ``./``, ``../``) as a relative local ref, + # mirroring the hydrator. + if not rest or rest.startswith("/") or "://" in rest: + return None + return rest + return None + + +def _validate_local_component_refs_for_artifact( + body_dict: dict[str, Any], + output_dir: Path, + planned_files: set[Path], + *, + artifact_label: str | None = None, +) -> None: + """Assert every RELATIVE local componentRef target either exists relative + to ``output_dir`` or is a file the compiler is about to write. + + Hydrate resolves componentRef URLs relative to the artifact YAML's own + location, so a relative ``file://./x`` / ``resolve://./x`` ref is only + resolvable if ``x`` sits next to that artifact. Generated bundle files + (child sidecars, ``@task`` components sidecars) are passed in + ``planned_files`` — they validate as "present" before they are written + (Decision J). External relative refs must already exist on disk. + + Args: + artifact_label: ``None`` for the ROOT (uses the legacy error wording + relative to the OUTPUT directory); a ``child pipeline '' + ()`` label for a child sidecar (uses child-context wording + relative to the child sidecar's directory). + + Raises: + CompileError: with actionable guidance when a referenced local + component file is unreachable. We do NOT silently copy files. + """ + tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {}) + for task_id, task in tasks.items(): + if not isinstance(task, dict): + continue + cref = task.get("componentRef") + if not isinstance(cref, dict): + continue + url = cref.get("url") + if not isinstance(url, str): + continue + rel = _relative_local_ref_target(url) + if rel is None: + continue + target = (output_dir / rel).resolve() + if target in planned_files: + continue # a sidecar this same compile is about to write + if target.exists(): + continue + if artifact_label is None: + raise CompileError( + f"task {task_id!r} references local component {url!r}, but the " + f"target does not exist relative to the output directory: " + f"{target}. Hydrate resolves componentRef URLs relative to the " + "compiled YAML's location, so the referenced file must sit " + "next to the compiled output. Fix options: compile into the " + "pipeline source directory so referenced files are colocated " + "with the output; place the referenced component next to the " + "compiled YAML; or use an absolute file:///… / gs://… URL or a " + "published name: ref." + ) + raise CompileError( + f"{artifact_label} task {task_id!r} references local component " + f"{url!r}, but hydrate will resolve it relative to the child " + f"sidecar directory: {target}. Place the referenced component next " + "to the child sidecar, compile into a bundle directory with that " + "layout, or use an absolute file:///… / gs://… / resolve://… " + "reference." + ) + + +# --------------------------------------------------------------------------- +# Orchestration helpers + + +def _parse_overrides(entries: list[str]) -> dict[str, str]: + """Parse ``--override key=value`` strings into a dict. + + Raises: + CompileError: if an entry has no ``=`` separator. + """ + overrides: dict[str, str] = {} + for entry in entries: + if "=" not in entry: + raise CompileError(f"--override expects key=value, got {entry!r} (no '=' separator)") + key, _, value = entry.partition("=") + overrides[key] = value + return overrides + + +def _evict_shadowed_bundle_modules(bundle_dir: Path) -> None: + """Evict ``sys.modules`` entries that shadow a module file in ``bundle_dir``. + + Python caches imported modules by NAME, not path, so a previously imported + top-level module (from a prior in-process compile, a test helper, or a + REPL) named e.g. ``child_pipeline`` would be reused by a sibling + ``from child_pipeline import ...`` even when the bundle being compiled + ships its OWN, DIFFERENT ``child_pipeline.py``. Evicting any cached entry + whose name matches a ``.py`` stem in ``bundle_dir`` but whose ``__file__`` + differs forces a FRESH import from the bundle's ``sys.path``. Re-importing + is always safe; the after-compile purge then drops the freshly imported + entries too, leaving global import state clean. + """ + try: + entries = list(bundle_dir.iterdir()) + except OSError: # pragma: no cover — defensive (missing/inaccessible dir) + return + for entry in entries: + if entry.suffix != ".py": + continue + mod = sys.modules.get(entry.stem) + if mod is None: + continue + file = getattr(mod, "__file__", None) + if not file: + continue + try: + same = Path(file).resolve() == entry.resolve() + except OSError: # pragma: no cover — defensive + same = False + if not same: + del sys.modules[entry.stem] + + +def _purge_bundle_local_modules(source_dirs: set[Path]) -> None: + """Drop every ``sys.modules`` entry loaded from the bundle's source tree. + + ``_load_pipeline_fn`` exec's the root module under a unique synthetic name + (restored in its own ``finally``), but the root's top-level + ``from sibling import ...`` statements — and any trace-time sibling + imports inside cycle-style children — register sibling/helper modules + under their REAL names in ``sys.modules``. Those entries are not cleaned + up by the synthetic-name / ``sys.path`` restoration, so a SUBSEQUENT + in-process compile of a DIFFERENT bundle that happens to define a module + with the SAME name (e.g. another ``child_pipeline.py`` in a different temp + dir) would reuse the STALE cached module and validate against the wrong + child. + + This is called AFTER the full compile (success or failure). By then the + traced functions already hold their child ``PipelineFn`` objects by + reference, so removing the ``sys.modules`` entries is safe — the only goal + is that the next compile re-imports fresh. + + A module is purged whenever its ``__file__`` resolves under one of + ``source_dirs`` (the root script dir and every compiled child's own source + dir). The ``modules_before`` set is deliberately NOT consulted: the + up-front eviction may have REPLACED a pre-existing cached name with this + bundle's own module, so a name that pre-dated the compile can still hold a + bundle-local module that must be cleared. Modules outside the bundle tree + (stdlib, site-packages, the CLI package itself) are never touched. The + leak-free synthetic-root-name and ``sys.path`` restoration in + ``_load_pipeline_fn`` / ``_temp_sys_path`` is left intact. + """ + resolved_dirs: list[Path] = [] + for d in source_dirs: + try: + resolved_dirs.append(d.resolve()) + except OSError: # pragma: no cover — defensive + continue + if not resolved_dirs: + return + for name in list(sys.modules): + mod = sys.modules.get(name) + file = getattr(mod, "__file__", None) + if not file: + continue + try: + mod_path = Path(file).resolve() + except OSError: # pragma: no cover — defensive + continue + if any(mod_path.is_relative_to(d) for d in resolved_dirs): + del sys.modules[name] + + +def _candidate_names(fn: PipelineFn) -> tuple[str, str]: + """Return ``(function_name, display_name)`` for ``--pipeline`` matching. + + ``function_name`` is the decorated function's ``__name__`` (a clean, + shell-friendly identifier such as ``options_standardization``); + ``display_name`` is the ``@pipeline(name=...)`` value emitted as the + YAML ``name:`` (may contain spaces, e.g. ``"Options Standardization"``). + Selection matches the function name first, then the display name. + """ + func_name = getattr(fn.fn, "__name__", "") or "" + return (func_name, fn.name) + + +def _format_candidates(candidates: list[PipelineFn]) -> str: + """Render candidates as ``function_name ("Display Name")`` for errors.""" + return ", ".join(f'{func} ("{disp}")' for func, disp in (_candidate_names(c) for c in candidates)) + + +def _select_by_name(candidates: list[PipelineFn], pipeline_name: str) -> list[PipelineFn]: + """Filter ``candidates`` matching ``pipeline_name``. + + The function ``__name__`` is matched first (preferred — it is the + shell-friendly identifier and the documented disambiguator); only when + no function name matches does the ``@pipeline`` display name apply. This + ordering means a file with two same-display-name siblings can always be + disambiguated by passing a function name. + """ + by_func = [c for c in candidates if _candidate_names(c)[0] == pipeline_name] + if by_func: + return by_func + return [c for c in candidates if _candidate_names(c)[1] == pipeline_name] + + +def _load_pipeline_fn(module_path: Path, pipeline_name: str | None = None) -> PipelineFn: + """Load ``module_path`` as a module and return a single PipelineFn. + + When the file defines exactly one root pipeline it is returned + directly. When it defines several, ``pipeline_name`` selects which one + to compile (matched against the function ``__name__`` first, then the + ``@pipeline`` display name — Decision G / ``--pipeline``). The selected + pipeline's same-file nested children are still reachable to + ``subpipeline(child)(...)`` and are compiled via the recursion driver, + never via this candidate list. + + Leak-free: the module's parent dir is temporarily added to + ``sys.path`` (so top-level ``import`` of sibling modules resolves + during exec) and the module is registered under a UNIQUE name in + ``sys.modules``. Both are restored in a ``finally`` so repeated or + concurrent in-process compiles never collide and no global import + state leaks. The loaded module object stays alive via the returned + ``PipelineFn`` regardless of the ``sys.modules`` cleanup. + """ + module_dir = str(module_path.parent) + # Unique module name so repeated/concurrent in-process compiles don't + # collide and so we can cleanly remove our own sys.modules entry. + module_name = f"_tangle_user_pipeline_{uuid.uuid4().hex}" + + spec = importlib.util.spec_from_file_location(module_name, str(module_path)) + if spec is None or spec.loader is None: + raise CompileError(f"could not load module spec from {module_path}") + mod = importlib.util.module_from_spec(spec) + + # Snapshot global import state so we can restore it leak-free. + added_to_sys_path = module_dir not in sys.path + prior_mod = sys.modules.get(module_name) + if added_to_sys_path: + sys.path.insert(0, module_dir) + sys.modules[module_name] = mod + try: + spec.loader.exec_module(mod) + except Exception as e: + # Re-surface user-module exceptions wrapped as CompileError so + # the CLI exits 1 cleanly. + raise CompileError(f"error importing pipeline file {module_path}: {e}") from e + finally: + # Restore the sys.modules entry (delete ours, or put back prior). + if prior_mod is not None: + sys.modules[module_name] = prior_mod + else: + sys.modules.pop(module_name, None) + # Remove the dir from sys.path only if we added it. + if added_to_sys_path: + try: + sys.path.remove(module_dir) + except ValueError: # pragma: no cover — defensive + pass + + all_candidates = [v for v in vars(mod).values() if isinstance(v, PipelineFn)] + if not all_candidates: + raise CompileError(f"no @pipeline-decorated function found in {module_path}") + + # Root discovery selects the pipeline(s) DEFINED IN the target file. A + # parent module that imports child pipelines (so it can wrap them with + # ``subpipeline(child)(...)``) must not count those imports as roots + # (Decision G). Compare each candidate's source file against + # ``module_path``; imported children resolve to a DIFFERENT file and are + # ignored as compile targets (they remain reachable to ``subpipeline``). + # Several pipelines DEFINED in this one file are now allowed: when more + # than one is local the caller selects which to emit via ``pipeline_name`` + # (``--pipeline``); same-file nested children are then compiled through + # the ``subpipeline`` recursion, not picked from this candidate list. + target = module_path.resolve() + local_candidates: list[PipelineFn] = [] + undetermined: list[PipelineFn] = [] + for cand in all_candidates: + src = _pipeline_source_path(cand) + if src is None: + undetermined.append(cand) + elif src == target: + local_candidates.append(cand) + # else: defined in another file (imported child) -> not a root. + + if not local_candidates and not undetermined: + # Every candidate is defined in another file: the target imports + # pipelines but defines none of its own. + names = [getattr(c, "name", "?") for c in all_candidates] + raise CompileError( + f"no @pipeline-decorated function defined in {module_path}; found " + f"only imported pipeline(s): {names!r}. Define the compile-target " + "@pipeline in this file (imported child pipelines are wrapped with " + "subpipeline(child)(...), not compiled directly)." + ) + + if pipeline_name is not None: + # Explicit selection (``--pipeline``). Match against the function + # name then the display name, across local AND undetermined-source + # candidates (D4 — an exec'd module still exposes ``fn.__name__``). + # Imported children are never selectable as a root. + searchable = [*local_candidates, *undetermined] + matches = _select_by_name(searchable, pipeline_name) + if len(matches) == 1: + return matches[0] + if not matches: + raise CompileError( + f"pipeline {pipeline_name!r} not found in {module_path}; " + f"available: {_format_candidates(searchable)}." + ) + # Two or more matched (same display name on distinct functions). + func_names = ", ".join(_candidate_names(c)[0] for c in matches) + raise CompileError( + f"pipeline name {pipeline_name!r} is ambiguous in {module_path}; " f"use the function name: {func_names}." + ) + + # No explicit selection. Prefer locals; fall back to undetermined with + # the single-candidate dynamic auto-select (Decision G / D4): a single + # exec-built PipelineFn whose source cannot be resolved is still a valid + # root, but two undetermined candidates cannot be told apart from + # imports, so they fall through to the multiple-pipelines error. + if local_candidates: + candidates = local_candidates + else: + if len(all_candidates) == 1: + return all_candidates[0] + candidates = undetermined + + if len(candidates) == 1: + return candidates[0] + + raise CompileError( + f"multiple @pipeline-decorated functions found in {module_path}: " + f"{_format_candidates(candidates)}. Pass --pipeline to select " + "one (function name or display name)." + ) + + +def _pipeline_source_path(pipeline_fn: PipelineFn) -> Path | None: + """Resolve the source file that DEFINES ``pipeline_fn``'s function. + + Returns the symlink-resolved :class:`Path`, or ``None`` when the + source is UNDETERMINED — i.e. a dynamically built function whose + source cannot be located. Used by :func:`_load_pipeline_fn` to + distinguish locally defined root pipelines from imported child + pipelines, and to honour Decision G's single-candidate dynamic + fallback. + + A source is treated as undetermined (``None``) when: + + * it cannot be read at all (``inspect`` failure, no ``co_filename``); + * it is a pseudo filename produced by ``exec``/``eval``/the REPL, + e.g. ```` / ```` (matched as ``<...>``); or + * the resolved path does not exist on disk (so it cannot be + meaningfully compared against the real target module file). + """ + fn = pipeline_fn.fn + src: str | None + try: + src = inspect.getsourcefile(fn) or inspect.getfile(fn) + except (TypeError, OSError): + src = None + if not src: + code = getattr(fn, "__code__", None) + src = getattr(code, "co_filename", None) + if not src: + return None + # Pseudo filenames from exec/eval/REPL ("", "", ...) + # are not real on-disk sources — treat as undetermined so the + # single-candidate dynamic fallback in _load_pipeline_fn can apply. + if src.startswith("<") and src.endswith(">"): + return None + try: + resolved = Path(src).resolve() + except OSError: # pragma: no cover — defensive + return None + # A non-existent path (e.g. a synthetic co_filename) cannot be + # compared against the target module file — treat as undetermined. + if not resolved.exists(): + return None + return resolved + + +def _pipeline_accepts_cfg(pipeline_fn: PipelineFn) -> bool: + """Return whether the pipeline function has a real ``cfg`` parameter. + + ``@pipeline(config=...)`` only matters when the authored function accepts a + parameter named ``cfg`` (and that parameter is not an ``In[T]`` graph input). + Pipelines without such a parameter cannot observe compile-time config, so + requiring the file would make stale decorator metadata unnecessarily fatal. + """ + try: + sig = inspect.signature(pipeline_fn.fn) + except (TypeError, ValueError): # pragma: no cover — defensive + return False + param = sig.parameters.get("cfg") + if param is None: + return False + + annotation = param.annotation + try: + resolved_hints = get_type_hints(pipeline_fn.fn, include_extras=True) + annotation = resolved_hints.get("cfg", annotation) + except Exception: + pass + return getattr(annotation, "__origin__", None) is not In + + +def _resolve_cfg_path(pipeline_fn: PipelineFn, module_path: Path) -> Path: + """Resolve the cfg path declared by ``@pipeline(config=...)``. + + Relative to the file holding the decorated function. If the + decorator omits ``config=``, returns the module's directory / + ``config.yaml`` as the default. Thin wrapper over + :func:`_resolve_cfg_path_in_dir` for callers that have the module FILE + rather than its directory. + """ + return _resolve_cfg_path_in_dir(pipeline_fn, module_path.parent) + + +def _resolve_cfg_path_in_dir(pipeline_fn: PipelineFn, base_dir: Path) -> Path: + """Resolve ``@pipeline(config=...)`` relative to ``base_dir``. + + ``base_dir`` is the pipeline's OWN source directory — the root script's + parent for the root, or the child ``PipelineFn``'s source directory for + a nested child (Decision F: each pipeline loads its own config relative + to its own file). If the decorator omits ``config=``, defaults to + ``/config.yaml``. + """ + if pipeline_fn.config_path: + cfg_path = Path(pipeline_fn.config_path) + else: + cfg_path = Path("config.yaml") + if not cfg_path.is_absolute(): + cfg_path = base_dir / cfg_path + return cfg_path.resolve() + + +def _assert_config_output_path_is_separate( + cfg_path: Path, + output_path: Path, + *, + pipeline_fn: PipelineFn, + is_root: bool, +) -> None: + """Reject ``@pipeline(config=...)`` paths that collide with output. + + The config file is a compile-time INPUT read before the compiler writes + anything. Treating a missing output path as an empty config would mask + typos, and treating an existing output path as config would make the + compiler read a previous compiled artifact as its input config. Fail + before any writes with guidance that explains the two distinct paths. + """ + if cfg_path.resolve() != output_path.resolve(): + return + + output_label = "--output" if is_root else "generated child sidecar output" + raise CompileError( + f"@pipeline config for {pipeline_fn.name!r} resolves to the same path as " + f"the {output_label}: {cfg_path}. The `config=` argument names a " + "compile-time input config file; it is read before the compiled YAML " + "is written. tangle-deploy creates the output file automatically after " + "validation, so do not point `config=` at the output. Use a separate " + "config file (for example an empty `*.compile_config.yaml`) or omit " + "`config=` to use `config.yaml`, and keep `--output` for the compiled YAML." + ) + + +def _load_cfg_and_raw( + cfg_path: Path, + overrides: Mapping[str, Any], + *, + coerce: bool = True, + pipeline_fn: PipelineFn, + usage: str = "cfg", +): + """Load cfg via ``cfg.load_cfg`` AND read the raw YAML dict. + + The raw dict (config.yaml WITHOUT overrides applied) is returned so the + caller can build a broadcast layer from this pipeline's OWN config. + ``coerce`` is threaded to :func:`load_cfg`: the ROOT passes ``coerce=True`` + so its CLI ``--override`` strings keep YAML coercion; a CHILD passes + ``coerce=False`` so its already-typed native ``effective`` overrides pass + through unchanged. Overrides are applied to the :class:`Cfg` object only — + the compiled YAML carries no config keys. + """ + import yaml + + if not cfg_path.exists(): + source = ( + f"@pipeline(config={pipeline_fn.config_path!r})" + if pipeline_fn.config_path + else "the default config.yaml" + ) + if usage == "propagate_config": + reason = ( + "has propagate_config=True, so " + f"{source} is required as the config payload to broadcast to " + "descendant subpipelines. Create the file, remove `config=`, " + "or disable propagate_config if there is no config to broadcast." + ) + else: + reason = ( + "has a `cfg` parameter, so " + f"{source} is required as a compile-time input. Create the file, " + "or remove the `cfg` parameter (and omit `config=`) if the " + "pipeline does not use compile-time config." + ) + raise CompileError( + f"config file not found: {cfg_path}. Pipeline {pipeline_fn.name!r} {reason}" + ) + raw = yaml.safe_load(cfg_path.read_text()) or {} + if not isinstance(raw, dict): + raise CompileError(f"config at {cfg_path} must be a YAML mapping, got {type(raw).__name__}") + cfg = load_cfg(cfg_path, overrides=dict(overrides), coerce=coerce) + return cfg, raw + + +def _read_raw_cfg(cfg_path: Path) -> dict[str, Any]: + """Read a child's raw config.yaml as a plain mapping (no overrides). + + Used to drive the strict/lenient override resolution in + :func:`_effective_overrides_for_child` — both the broadcast same-name + overlay and the explicit ``.override_config`` typo check key off the keys + the child actually declares. Mirrors the error style of + :func:`_load_cfg_and_raw` (missing file / non-mapping config). + """ + import yaml + + if not cfg_path.exists(): + raise CompileError(f"config file not found: {cfg_path}") + raw = yaml.safe_load(cfg_path.read_text()) or {} + if not isinstance(raw, dict): + raise CompileError(f"config at {cfg_path} must be a YAML mapping, got {type(raw).__name__}") + return raw + + +def _resolve_broadcast_stack( + broadcast_stack: list[BroadcastLayer], + *, + key_filter: set[str] | None, +) -> dict[str, Any]: + """Resolve the active broadcast stack into a single key/value map applying + the two precedence tiers from PROPAGATE_CONFIG_DESIGN §4 (lines 128-159). + + Tiers, lowest -> highest: + + 1. **Broadcast tier** — layers with ``explicit=False`` (a flagged + pipeline's own config). Iterated OUTER -> INNER so the nearest layer + that defines a key wins WITHIN the tier. + 2. **Explicit tier** — layers with ``explicit=True`` (a flagged caller's + per-edge ``.override_config`` that flows deep). Iterated OUTER -> INNER + (nearest-wins) and applied AFTER the entire broadcast tier, so the + explicit tier ALWAYS outranks the broadcast tier regardless of depth. + + ``key_filter`` is the lenient same-name guard: when given (the set of keys + a node actually declares), only those keys are folded in — keys the node + does not declare are skipped. Pass ``None`` to fold in EVERY key present in + the stack (used by the ambient pass-through resolver, which needs the full + resolved context over all keys regardless of what any single node declares). + + This is the single source of the tier logic; both + :func:`_effective_overrides_for_child` and :func:`_resolve_ambient_context` + route through it so their precedence can never diverge. + """ + + def _allowed(key: str) -> bool: + return key_filter is None or key in key_filter + + resolved: dict[str, Any] = {} + # Broadcast tier first (own-config layers), outer -> inner (inner wins). + for layer in broadcast_stack: + if layer.explicit: + continue + for key, value in layer.config.items(): + if _allowed(key): + resolved[key] = value + # Explicit tier on top (per-edge override layers), outer -> inner. Applied + # after the whole broadcast tier so explicit always outranks broadcast. + for layer in broadcast_stack: + if not layer.explicit: + continue + for key, value in layer.config.items(): + if _allowed(key): + resolved[key] = value + return resolved + + +def _effective_overrides_for_child( + *, + child_raw: Mapping[str, Any], + broadcast_stack: list[BroadcastLayer], + explicit: Mapping[str, Any], + child_name: str, + parent_task_id: str, +) -> dict[str, Any]: + """Resolve a child's effective overrides across the two precedence tiers + plus the strict direct edge (PROPAGATE_CONFIG_DESIGN §4, lines 128-159). + + Layered lowest -> highest precedence: + + 1. **Broadcast tier** (lenient, nearest-wins) — flagged ancestors' own + config flowing deep. Applied only for keys the child declares + (``key in child_raw``). + 2. **Explicit tier** (lenient, nearest-wins) — a flagged caller's per-edge + ``.override_config`` that flows deep. Applied AFTER the whole broadcast + tier so it always outranks broadcast, even from a NEARER flagged + descendant. Still lenient (``key in child_raw``). + 3. The **direct edge** ``.override_config`` into THIS child (``explicit`` + param) — STRICT: a key NOT present in the child's own ``config.yaml`` + is a :class:`CompileError` (typo protection); otherwise it overlays, + winning over both tiers. + + Tiers 1-2 are resolved by the shared :func:`_resolve_broadcast_stack` + helper (with the child's declared keys as the lenient ``key_filter``). + """ + declared = set(child_raw) + effective: dict[str, Any] = _resolve_broadcast_stack(broadcast_stack, key_filter=declared) + # 3. Direct-edge explicit overrides — strict, wins over both tiers. + for key, value in explicit.items(): + if key not in child_raw: + raise CompileError( + f"subpipeline task {parent_task_id!r} sets .override_config(" + f"{key}=...) for child pipeline {child_name!r}, but {key!r} is " + f"not a key in that child's config.yaml. Declared child config " + f"keys: {sorted(child_raw)}." + ) + effective[key] = value + return effective + + +def _resolve_ambient_context(broadcast_stack: list[BroadcastLayer]) -> dict[str, Any]: + """Resolve the FULL ambient context map over ALL keys in the active stack. + + Applies the SAME two precedence tiers as + :func:`_effective_overrides_for_child` via the shared + :func:`_resolve_broadcast_stack` helper, but with NO ``key_filter`` — every + key present in the stack is folded in, regardless of what any single node + declares. Used by Fix B to compute the ambient PASS-THROUGH context (§7): + the keys that flow PAST a node, unchanged, to its descendants. + """ + return _resolve_broadcast_stack(broadcast_stack, key_filter=None) + + +# --------------------------------------------------------------------------- +# Command handler + + +class PipelineCompiler(TangleCliHandler): + """Compile a Python-authored pipeline to a dehydrated YAML bundle. + + The object-oriented entry point for the compile command, mirroring + :class:`tangle_cli.pipeline_hydrator.PipelineHydrator`: a + :class:`~tangle_cli.handler.TangleCliHandler` subclass that drives the + module-level :func:`compile_pipeline` free functions and reports the + written artifacts (and any non-fatal warnings) through ``self.log``. + + Compilation is fully offline — it traces the local Python authoring file + and emits YAML — so no Tangle API client is required; the handler base is + still used for its shared logger/``dry_run`` plumbing and to give + downstream distributions a single class to subclass. + + Distributions that carry a zone concept subclass this handler (and extend + the :data:`ZONE_ROOT_MARKERS` seam) so an explicit relative + ``@registered(gen_config=...)`` resolves against a zone root, while the + generic trace/emit/validate/write logic stays here. + """ + + def compile_file( + self, + script: Path, + output: Path, + *, + overrides: Mapping[str, str] | None = None, + pipeline_name: str | None = None, + emit_components_sidecar: bool = True, + ) -> CompileResult: + """Compile ``script`` to a single dehydrated pipeline YAML at ``output``. + + Thin object-oriented wrapper over :func:`compile_pipeline`: it runs the + compile and logs the written artifact paths and any non-fatal warnings + through ``self.log``. See :func:`compile_pipeline` for the full + argument, return, and error contract — in particular it raises + :class:`CompileError` for user-facing problems (missing script, no / + multiple ``@pipeline`` functions, invalid config, unreachable + ``@task`` sources) and :class:`SchemaValidationError` when a compiled + artifact fails dehydrated-schema validation. + """ + result = compile_pipeline( + script, + output, + overrides, + pipeline_name=pipeline_name, + emit_components_sidecar=emit_components_sidecar, + ) + self.log.info(f"wrote {result.pipeline_path}") + if result.components_path is not None: + self.log.info(f"wrote {result.components_path}") + for subgraph_path in result.subgraph_paths: + self.log.info(f"wrote {subgraph_path}") + self.log.info(f"compiled {result.task_count} task(s)") + for warning in result.warnings: + self.log.info(f"warning: {warning}") + return result diff --git a/packages/tangle-cli/src/tangle_cli/pipelines.py b/packages/tangle-cli/src/tangle_cli/pipelines.py index 06ad8f7..a6c2daa 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines.py @@ -11,12 +11,15 @@ from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping +from typing import TYPE_CHECKING, Any, Iterable, Mapping import yaml from .utils import dump_yaml +if TYPE_CHECKING: + from .pipeline_compiler import CompileResult + PIPELINE_GRAPH_PATH = "implementation.graph" TASKS_PATH = f"{PIPELINE_GRAPH_PATH}.tasks" POSITION_ANNOTATION = "editor.position" @@ -419,6 +422,49 @@ def hydrate_pipeline_file( ) +# --------------------------------------------------------------------------- +# Compile +# --------------------------------------------------------------------------- + + +def compile_pipeline_file( + script: str | Path, + output: str | Path, + *, + overrides: Mapping[str, str] | None = None, + pipeline_name: str | None = None, + emit_components_sidecar: bool = True, + logger: Any | None = None, +) -> CompileResult: + """Compile a Python-authored pipeline to a dehydrated YAML bundle. + + Instantiates the ported :class:`~tangle_cli.pipeline_compiler.PipelineCompiler` + handler and delegates to its ``compile_file`` method, translating the + compiler's domain errors into :class:`PipelineValidationError` for a uniform + CLI failure contract. Mirrors :func:`hydrate_pipeline_file`. + + The :class:`~tangle_cli.pipeline_compiler.CompileResult` is returned as-is — + unlike hydrate, the compiler already exposes its public result type, so there + is nothing to repackage. + """ + + from .pipeline_compiler import PipelineCompiler + from .python_pipeline.errors import CompileError + from .schema_validation import SchemaValidationError + + compiler = PipelineCompiler(logger=logger) + try: + return compiler.compile_file( + Path(script), + Path(output), + overrides=dict(overrides) if overrides else None, + pipeline_name=pipeline_name, + emit_components_sidecar=emit_components_sidecar, + ) + except (CompileError, SchemaValidationError) as exc: + raise PipelineValidationError(str(exc)) from exc + + # --------------------------------------------------------------------------- # Layout # --------------------------------------------------------------------------- diff --git a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py index 4f545d7..6f5f22b 100644 --- a/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +++ b/packages/tangle-cli/src/tangle_cli/pipelines_cli.py @@ -19,6 +19,7 @@ from .logger import logger_for_log_type from .pipelines import ( PipelineValidationError, + compile_pipeline_file, generate_mermaid, hydrate_pipeline_file, layout_pipeline_file, @@ -125,6 +126,18 @@ def _parse_vars(values: list[str] | dict[str, object] | None) -> dict[str, str]: return parsed +def _parse_overrides(values: list[str] | None) -> dict[str, str]: + parsed: dict[str, str] = {} + for value in values or []: + if "=" not in value: + raise SystemExit("--override entries must use KEY=VALUE syntax") + key, parsed_value = value.split("=", 1) + if not key: + raise SystemExit("--override entries must use KEY=VALUE syntax") + parsed[key] = parsed_value + return parsed + + @app.command(name="hydrate") def pipelines_hydrate( pipeline_path: pathlib.Path, @@ -223,6 +236,66 @@ def pipelines_hydrate( ) +@app.command(name="compile") +def pipelines_compile( + pipeline_path: pathlib.Path, + *, + output: Annotated[ + pathlib.Path, + Parameter( + name="--output", + alias="-o", + help="Output path for the compiled dehydrated pipeline YAML.", + ), + ], + pipeline: Annotated[ + str | None, + Parameter( + name="--pipeline", + help=( + "Select the root @pipeline function by name when the file " + "defines several." + ), + ), + ] = None, + override: Annotated[ + list[str] | None, + Parameter( + name="--override", + help="Compile-time config override as KEY=VALUE. Repeat for multiple.", + negative_iterable=(), + ), + ] = None, + log_type: LogTypeOption = "console", +) -> None: + """Compile a Python-authored pipeline to a dehydrated YAML bundle.""" + + logger, finalize_logs = logger_for_log_type(log_type) + try: + result = compile_pipeline_file( + pipeline_path, + output, + overrides=_parse_overrides(override), + pipeline_name=pipeline, + logger=logger, + ) + except PipelineValidationError as exc: + raise SystemExit(str(exc)) from exc + finally: + finalize_logs() + + print( + f"Compiled {pipeline_path} -> {result.pipeline_path} " + f"({result.task_count} task(s))." + ) + if result.components_path is not None: + print(f"Wrote component sidecar: {result.components_path}") + for subgraph_path in result.subgraph_paths: + print(f"Wrote subgraph: {subgraph_path}") + for warning in result.warnings: + print(f"warning: {warning}") + + @app.command(name="layout") def pipelines_layout( pipeline_path: pathlib.Path, diff --git a/packages/tangle-cli/src/tangle_cli/schema_validation.py b/packages/tangle-cli/src/tangle_cli/schema_validation.py new file mode 100644 index 0000000..5179f2a --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/schema_validation.py @@ -0,0 +1,460 @@ +"""Standalone validation helpers for the *dehydrated* pipeline schema. + +This module is intentionally decoupled from the hydrator: it only loads +the packaged ``dehydrated_pipeline_schema.json`` and offers pure helpers +the compiler (and tests) can call. It does NOT change any existing +``PipelineHydrator`` behavior. + +Public surface: + +* :func:`load_dehydrated_schema` — load (and cache) the packaged schema. +* :func:`validate_dehydrated_data` — JSON-Schema (Draft 2020-12) + validation, raising :class:`SchemaValidationError` with the single + best/most-specific message. +* :func:`iter_template_delimiters` / :func:`assert_no_template_delimiters` + — generic "no template delimiters" output contract: the compiled + output must contain no ``{{``, ``{%`` or ``{#`` in any string. This is + generic (not SQL/Jinja aware): a compiled dehydrated pipeline carries + final rendered values only. +* :func:`is_dehydrated_pipeline` — shape detector (no raise): top-level + ``name`` + ``implementation.graph.tasks``, no ``template_file``, and + task ``arguments`` values that are raw string constants or ``graphInput`` + / ``taskOutput`` wrappers. +* :func:`validate_dehydrated_pipeline` — JSON-Schema validation PLUS the + deeper semantic checks jsonschema cannot express cleanly (dangling + ``taskOutput.taskId``, undeclared ``graphInput.inputName``, + ``outputValues`` ↔ ``outputs`` correspondence, scalar metadata + annotations, pure componentRefs) and the no-template-delimiter scan. + +Everything here is standalone — it never changes ``PipelineHydrator`` +behavior. ``compile_pipeline`` uses :func:`validate_dehydrated_pipeline` +for its richer pre-write check; an explicit, read-only +``PipelineHydrator.validate_dehydrated_file`` convenience also delegates +here. +""" +from __future__ import annotations + +import json +from collections.abc import Collection, Iterator, Mapping +from functools import lru_cache +from pathlib import Path +from typing import Any + +import jsonschema +from jsonschema.validators import validator_for + +# Template delimiters that must never appear in a compiled dehydrated +# pipeline's strings. The compiled output is the final, rendered form; +# any surviving delimiter means an upstream template was not rendered. +_TEMPLATE_DELIMITERS = ("{{", "{%", "{#") + +_SCHEMA_FILENAME = "dehydrated_pipeline_schema.json" + + +class SchemaValidationError(ValueError): + """Raised when a dehydrated pipeline fails schema/contract validation. + + The compiler wraps this in a ``CompileError`` so the CLI exits 1 with + a friendly message; tests may assert on it directly. + """ + + +def _schema_path() -> Path: + """Locate the packaged dehydrated schema JSON. + + Prefers :mod:`importlib.resources` so it resolves when ``tangle_cli`` + is installed as a wheel (``schemas/*`` is declared package data). Falls + back to a path next to this module for editable / source checkouts. + """ + try: + from importlib.resources import files + + resource = files("tangle_cli") / "schemas" / _SCHEMA_FILENAME + # ``as_file`` would be needed for zip imports, but tangle_cli is + # always installed unzipped; a direct filesystem path is fine here. + candidate = Path(str(resource)) + if candidate.exists(): + return candidate + except (ModuleNotFoundError, FileNotFoundError, TypeError): # pragma: no cover + pass + # Fallback: alongside this module. + return Path(__file__).parent / "schemas" / _SCHEMA_FILENAME + + +@lru_cache(maxsize=1) +def load_dehydrated_schema() -> dict[str, Any]: + """Load and cache the packaged dehydrated pipeline JSON schema.""" + path = _schema_path() + try: + return json.loads(path.read_text()) + except FileNotFoundError as e: # pragma: no cover — packaging error + raise SchemaValidationError( + f"dehydrated pipeline schema not found at {path}" + ) from e + + +def validate_dehydrated_data(data: Mapping[str, Any]) -> None: + """Validate ``data`` against the dehydrated pipeline schema. + + Uses the draft declared in the schema's ``$schema`` (Draft 2020-12) + via :func:`jsonschema.validators.validator_for`. + + Raises: + SchemaValidationError: with the single best/most-specific message + when ``data`` does not conform. + """ + schema = load_dehydrated_schema() + validator_cls = validator_for(schema) + validator_cls.check_schema(schema) + validator = validator_cls(schema) + + errors = sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)) + if not errors: + return + + best = jsonschema.exceptions.best_match(errors) + if best is None: # pragma: no cover — errors is non-empty here + best = errors[0] + location = ( + ".".join(str(p) for p in best.absolute_path) + if best.absolute_path + else "root" + ) + raise SchemaValidationError( + f"dehydrated pipeline failed schema validation at {location}: " + f"{best.message}" + ) + + +def iter_template_delimiters(data: Any, _path: str = "") -> Iterator[tuple[str, str]]: + """Yield ``(json_path, delimiter)`` for every string containing a + template delimiter, walking ``data`` recursively. + + Generic scan: keys and values of mappings and items of sequences are + all inspected. The scan is operation-agnostic — it knows nothing + about SQL or Jinja semantics, only the three delimiter tokens. + """ + if isinstance(data, str): + for delim in _TEMPLATE_DELIMITERS: + if delim in data: + yield _path or "root", delim + return + if isinstance(data, Mapping): + for key, value in data.items(): + key_path = f"{_path}.{key}" if _path else str(key) + # Inspect the key itself too — keys are emitted verbatim. + if isinstance(key, str): + for delim in _TEMPLATE_DELIMITERS: + if delim in key: + yield f"{key_path} (key)", delim + yield from iter_template_delimiters(value, key_path) + return + if isinstance(data, (list, tuple)): + for i, item in enumerate(data): + item_path = f"{_path}[{i}]" + yield from iter_template_delimiters(item, item_path) + return + + +def assert_no_template_delimiters( + data: Mapping[str, Any], + exempt_paths: Collection[str] = (), +) -> None: + """Assert the compiled output contains no template delimiters. + + Args: + data: the compiled (dehydrated) pipeline dict to scan. + exempt_paths: JSON paths (in the dot-delimited form + :func:`iter_template_delimiters` yields) whose delimiters are a + legitimate RUNTIME placeholder and must NOT fail the guard — + e.g. a ``run-query`` ``sql_query`` carrying a ``{{input_1}}`` + sentinel the op substitutes at run time, authored via + :func:`tangle_cli.python_pipeline.raw`. An offender at one of + these exact paths is skipped; every OTHER delimiter still fails, + so real compile-time template leaks are still caught. Defaults + to no exemptions, so existing callers are unaffected. + + Raises: + SchemaValidationError: listing the offending locations when any + non-exempt string contains ``{{``, ``{%`` or ``{#``. + """ + allowed = set(exempt_paths) + offenders = [ + (path, delim) + for path, delim in iter_template_delimiters(data) + if path not in allowed + ] + if not offenders: + return + detail = "; ".join(f"{path} contains {delim!r}" for path, delim in offenders) + raise SchemaValidationError( + "compiled pipeline output must contain no template delimiters " + "({{, {% or {#}) — the dehydrated output carries final rendered " + f"values only. Offending location(s): {detail}. Render any " + "templated value in your pipeline code before passing it as a " + "task argument, or wrap a genuine runtime placeholder in " + "tangle_cli.python_pipeline.raw(...) to mark it intentional." + ) + + +# --------------------------------------------------------------------------- +# Phase 5: dehydrated-pipeline shape detection + semantic validation. +# +# These helpers are standalone. They never touch PipelineHydrator behavior. + +# The ONLY top-level keys a dehydrated pipeline may carry. Compile-time +# config is baked into raw string constants and never emitted, so anything +# else at the top level is a leaked config key. +_ALLOWED_TOP_LEVEL_KEYS = frozenset( + {"name", "description", "metadata", "inputs", "outputs", "implementation"} +) + +# The reference-only ArgumentValue wrappers. A constant is NOT a wrapper — +# it is a raw string (matching the runnable Tangle argument contract). +_REFERENCE_ARGUMENT_KEYS = ("graphInput", "taskOutput") + + +def _is_argument_value(value: Any) -> bool: + """True when ``value`` looks like a runnable ArgumentValue — a raw + string constant, or a mapping carrying a ``graphInput`` / ``taskOutput`` + wrapper. + + There is no ambiguity: a raw string constant (even one whose text is + ``"graphInput"`` or JSON like ``'{"graphInput": ...}'``) is a string, + never the object wrapper shapes — so it can never collide with a + ``graphInput`` / ``taskOutput`` mapping. + """ + if isinstance(value, str): + return True + return isinstance(value, Mapping) and any( + key in value for key in _REFERENCE_ARGUMENT_KEYS + ) + + +def is_dehydrated_pipeline(data: Any) -> bool: + """Detect a *dehydrated* pipeline by shape (never raises). + + A dehydrated pipeline has: + + * a top-level ``name`` and ``implementation.graph.tasks`` (non-empty); + * NO ``template_file`` (it is the final rendered form, not a wrapper); + * task ``arguments`` values AND graph ``outputValues`` values (when + present) that are raw string constants or ``graphInput`` / + ``taskOutput`` wrappers — a non-string raw value (a bare + number/list/object) or a legacy ``{constantValue: ...}`` wrapper is + not a runnable argument value, so it means the input is not yet + dehydrated. + + This is intentionally lenient about everything else (it is a detector, + not a validator); use :func:`validate_dehydrated_pipeline` for strict + checking. + """ + if not isinstance(data, Mapping): + return False + if "template_file" in data: + return False + if "name" not in data: + return False + + implementation = data.get("implementation") + if not isinstance(implementation, Mapping): + return False + graph = implementation.get("graph") + if not isinstance(graph, Mapping): + return False + tasks = graph.get("tasks") + if not isinstance(tasks, Mapping) or not tasks: + return False + + for task in tasks.values(): + if not isinstance(task, Mapping): + return False + arguments = task.get("arguments") + if arguments is None: + continue + if not isinstance(arguments, Mapping): + return False + for value in arguments.values(): + if not _is_argument_value(value): + return False + + # Graph outputValues use the SAME runnable argument-value contract, so a + # legacy ``{constantValue: ...}`` (or any non-string raw value) there must + # not pass the detector either. + output_values = graph.get("outputValues") + if output_values is not None: + if not isinstance(output_values, Mapping): + return False + for value in output_values.values(): + if not _is_argument_value(value): + return False + return True + + +def _declared_names(specs: Any) -> set[str]: + """Collect the ``name`` values from an inputs/outputs spec list.""" + names: set[str] = set() + if isinstance(specs, list): + for spec in specs: + if isinstance(spec, Mapping) and isinstance(spec.get("name"), str): + names.add(spec["name"]) + return names + + +def _assert_pure_component_ref(component_ref: Any, loc: str) -> None: + """Assert a componentRef is a PURE ref — no inline ``spec`` / ``text``. + + Redundant with the schema's ``not`` clause, but kept as an explicit, + clearly-messaged semantic check. + """ + if not isinstance(component_ref, Mapping): + return + for forbidden in ("spec", "text"): + if forbidden in component_ref: + raise SchemaValidationError( + f"{loc} must be a pure reference; inline {forbidden!r} is " + "forbidden in a dehydrated pipeline (use url/digest/name)." + ) + + +def _check_argument_refs( + value: Any, + task_ids: set[str], + input_names: set[str], + *, + loc: str, +) -> None: + """Validate the graph references inside one ArgumentValue. + + * ``taskOutput.taskId`` must reference an emitted task id. + * ``graphInput.inputName`` must reference a declared top-level input. + """ + if not isinstance(value, Mapping): + return + task_output = value.get("taskOutput") + if isinstance(task_output, Mapping): + task_id = task_output.get("taskId") + if task_id not in task_ids: + raise SchemaValidationError( + f"{loc}: taskOutput.taskId {task_id!r} does not reference an " + f"emitted task. Known task ids: {sorted(task_ids)}." + ) + graph_input = value.get("graphInput") + if isinstance(graph_input, Mapping): + input_name = graph_input.get("inputName") + if input_name not in input_names: + raise SchemaValidationError( + f"{loc}: graphInput.inputName {input_name!r} does not reference " + f"a declared top-level input. Declared inputs: " + f"{sorted(input_names)}." + ) + + +def _validate_semantics(data: Mapping[str, Any]) -> None: + """Run the dehydrated semantic checks. Assumes ``data`` already passed + :func:`validate_dehydrated_data` (so the structure is well-formed).""" + implementation = data.get("implementation", {}) + graph = implementation.get("graph", {}) if isinstance(implementation, Mapping) else {} + tasks = graph.get("tasks", {}) if isinstance(graph, Mapping) else {} + task_ids = set(tasks.keys()) if isinstance(tasks, Mapping) else set() + + input_names = _declared_names(data.get("inputs")) + output_names = _declared_names(data.get("outputs")) + outputs_present = "outputs" in data + + if isinstance(tasks, Mapping): + for task_id, task in tasks.items(): + if not isinstance(task, Mapping): + continue + _assert_pure_component_ref( + task.get("componentRef"), f"tasks.{task_id}.componentRef" + ) + arguments = task.get("arguments") + if isinstance(arguments, Mapping): + for arg_name, value in arguments.items(): + _check_argument_refs( + value, + task_ids, + input_names, + loc=f"tasks.{task_id}.arguments.{arg_name}", + ) + + output_values = graph.get("outputValues") if isinstance(graph, Mapping) else None + if isinstance(output_values, Mapping): + for out_key, value in output_values.items(): + _check_argument_refs( + value, task_ids, input_names, loc=f"outputValues.{out_key}" + ) + if outputs_present and out_key not in output_names: + raise SchemaValidationError( + f"outputValues key {out_key!r} does not correspond to any " + f"declared top-level output. Declared outputs: " + f"{sorted(output_names)}." + ) + + metadata = data.get("metadata") + if isinstance(metadata, Mapping): + annotations = metadata.get("annotations") + if isinstance(annotations, Mapping): + for key, value in annotations.items(): + # bool is a subclass of int, so it is covered by int. + if not (value is None or isinstance(value, (str, int, float))): + raise SchemaValidationError( + f"metadata.annotations[{key!r}] must be a scalar " + f"(str/number/bool) or null, got " + f"{type(value).__name__!r}." + ) + + +def validate_dehydrated_pipeline( + data: Mapping[str, Any], + exempt_paths: Collection[str] = (), +) -> None: + """Strictly validate a *dehydrated* pipeline dict. + + Runs, in order: + + 1. a top-level guard — reject ``template_file`` and any top-level key + outside the schema-allowed set (leaked compile-time config); + 2. JSON-Schema (Draft 2020-12) structural validation + (:func:`validate_dehydrated_data`); + 3. the no-template-delimiter output contract + (:func:`assert_no_template_delimiters`); + 4. semantic checks: ``taskOutput.taskId`` / ``graphInput.inputName`` + existence, ``outputValues`` ↔ ``outputs`` correspondence, scalar + metadata annotations, and pure componentRefs. + + Args: + data: the dehydrated pipeline dict to validate. + exempt_paths: JSON paths whose template delimiters are legitimate + RUNTIME placeholders (authored via + :func:`tangle_cli.python_pipeline.raw`) and must be skipped + by the no-template-delimiter guard in step 3. Defaults to no + exemptions so existing callers are unaffected. See + :func:`assert_no_template_delimiters`. + + Raises: + SchemaValidationError: with a clear, specific message on the first + violation found. + """ + if not isinstance(data, Mapping): + raise SchemaValidationError( + f"dehydrated pipeline must be a mapping, got {type(data).__name__!r}." + ) + if "template_file" in data: + raise SchemaValidationError( + "dehydrated pipeline must not contain a 'template_file' key — it is " + "the final rendered form, not a Jinja template wrapper." + ) + extra = set(data) - _ALLOWED_TOP_LEVEL_KEYS + if extra: + raise SchemaValidationError( + "dehydrated pipeline has disallowed top-level key(s): " + f"{sorted(extra)}. Allowed top-level keys: " + f"{sorted(_ALLOWED_TOP_LEVEL_KEYS)}. Compile-time config is baked " + "into raw string constants and is never emitted at the top level." + ) + + validate_dehydrated_data(data) + assert_no_template_delimiters(data, exempt_paths) + _validate_semantics(data) diff --git a/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json b/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json new file mode 100644 index 0000000..47fcc8a --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json @@ -0,0 +1,254 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tangleml.com/schemas/tangle/dehydrated_pipeline_schema.json", + "title": "Tangle Dehydrated Pipeline Schema", + "description": "Formal schema for a Tangle pipeline in its *dehydrated* form. A dehydrated pipeline replaces every inline `componentSpec` with a lightweight `componentRef` (by url, digest, or name). Subgraphs are extracted to separate files and referenced via `componentRef.url` (typically `file://...`). Hydrate with `tangle sdk pipelines hydrate` to produce the full inline form.", + "type": "object", + "additionalProperties": false, + "required": ["name", "implementation"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Pipeline name." + }, + "description": { + "type": "string", + "description": "Free-form human-readable description." + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "inputs": { + "type": "array", + "items": { "$ref": "#/$defs/InputSpec" }, + "description": "Top-level pipeline inputs." + }, + "outputs": { + "type": "array", + "items": { "$ref": "#/$defs/OutputSpec" }, + "description": "Top-level pipeline outputs." + }, + "implementation": { + "type": "object", + "additionalProperties": false, + "required": ["graph"], + "properties": { + "graph": { "$ref": "#/$defs/GraphSpec" } + } + } + }, + + "$defs": { + "Metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { "type": ["string", "number", "boolean", "null"] } + }, + "labels": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + + "TypeName": { + "description": "Tangle parameter type. Common values include String, Integer, Float, Boolean, JsonObject, JsonArray, GcsPath, BqTable. New types may be added; treat as an open enum.", + "type": "string", + "minLength": 1 + }, + + "InputSpec": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "type": { "$ref": "#/$defs/TypeName" }, + "description": { "type": "string" }, + "default": {}, + "optional": { "type": "boolean" }, + "annotations": { "type": "object", "additionalProperties": true } + } + }, + + "OutputSpec": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "type": { "$ref": "#/$defs/TypeName" }, + "description": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": true } + } + }, + + "GraphSpec": { + "type": "object", + "additionalProperties": false, + "required": ["tasks"], + "properties": { + "tasks": { + "type": "object", + "minProperties": 1, + "propertyNames": { "minLength": 1 }, + "additionalProperties": { "$ref": "#/$defs/TaskSpec" }, + "description": "Map of task-id -> TaskSpec. Task ids are referenced from `taskOutput.taskId`." + }, + "outputValues": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/ArgumentValue" }, + "description": "Wire graph outputs to task outputs / graph inputs / constants." + } + } + }, + + "TaskSpec": { + "type": "object", + "additionalProperties": false, + "required": ["componentRef"], + "properties": { + "componentRef": { "$ref": "#/$defs/DehydratedComponentRef" }, + "arguments": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/ArgumentValue" }, + "description": "Map of component input name -> argument source." + }, + "isEnabled": { "type": "boolean" }, + "executionOptions": { "$ref": "#/$defs/ExecutionOptionsSpec" }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Free-form per-task annotations (e.g. `display_name`)." + } + } + }, + + "DehydratedComponentRef": { + "description": "Reference to a component. In a *dehydrated* pipeline this MUST be a pure reference — inline `spec`/`text` are forbidden. At least one of `url`, `digest`, or `name` must be present. `digest` may co-exist with `url`/`name` to pin to a specific version while still preserving the locator.", + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "minLength": 1, + "description": "Locator for the component YAML. Supported schemes: `file://`, `http(s)://`, `gs://`, `resolve://`.", + "anyOf": [ + { "pattern": "^file://" }, + { "pattern": "^https?://" }, + { "pattern": "^gs://" }, + { "pattern": "^resolve://" } + ] + }, + "digest": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 hex digest of the published component YAML. Auto-follows `superseded_by` at hydration time." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Component name as published in the Tangle Component Library. Resolves to the latest matching version at hydration time." + } + }, + "anyOf": [ + { "required": ["url"] }, + { "required": ["digest"] }, + { "required": ["name"] } + ], + "not": { + "anyOf": [ + { "required": ["spec"] }, + { "required": ["text"] } + ] + } + }, + + "ArgumentValue": { + "description": "Source of a task argument or graph output. Each argument has EXACTLY ONE source: a raw string constant, a `graphInput` wrapper, or a `taskOutput` wrapper (mixing sources in one object is rejected). The `graphInput` / `taskOutput` wrappers use the same shapes as runnable Tangle pipelines. The ONLY dehydrated-vs-runnable difference is `componentRef` (a lightweight ref) vs an inline `componentSpec`; argument values are identical to the runnable contract.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/$defs/GraphInputArgument" }, + { "$ref": "#/$defs/TaskOutputArgument" } + ] + }, + + "GraphInputArgument": { + "type": "object", + "additionalProperties": false, + "required": ["graphInput"], + "properties": { + "graphInput": { "$ref": "#/$defs/GraphInputReference" } + } + }, + + "GraphInputReference": { + "type": "object", + "required": ["inputName"], + "properties": { + "inputName": { "type": "string" }, + "type": { + "anyOf": [ + { "type": "string" }, + { "type": "object", "additionalProperties": true }, + { "type": "array", "items": {} }, + { "type": "null" } + ], + "default": null + } + } + }, + + "TaskOutputArgument": { + "type": "object", + "additionalProperties": false, + "required": ["taskOutput"], + "properties": { + "taskOutput": { "$ref": "#/$defs/TaskOutputReference" } + } + }, + + "TaskOutputReference": { + "type": "object", + "required": ["outputName", "taskId"], + "properties": { + "outputName": { "type": "string" }, + "taskId": { "type": "string" } + } + }, + + "ExecutionOptionsSpec": { + "type": "object", + "additionalProperties": true, + "properties": { + "cachingStrategy": { "$ref": "#/$defs/CachingStrategySpec" }, + "retryStrategy": { "$ref": "#/$defs/RetryStrategySpec" }, + "timeout": { "type": "string", "description": "Go-style duration, e.g. `30m`, `2h`." } + } + }, + + "CachingStrategySpec": { + "type": "object", + "additionalProperties": true, + "properties": { + "maxCacheStaleness": { + "type": ["string", "null"], + "description": "Maximum allowed cache age, e.g. `P7D` (ISO-8601) or `7d`." + } + } + }, + + "RetryStrategySpec": { + "type": "object", + "additionalProperties": true, + "properties": { + "maxRetries": { "type": "integer", "minimum": 0 }, + "backoff": { "type": "string", "description": "Go-style duration, e.g. `30s`." } + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index ce556a6..96ab1f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "docstring-parser>=0.16", "httpx>=0.28.1", "jinja2>=3.1", + "jsonschema>=4.0.0", "platformdirs>=4.10.0", "pydantic>=2.0", "pyyaml>=6.0", diff --git a/tests/fixtures/python_pipeline/config.yaml b/tests/fixtures/python_pipeline/config.yaml new file mode 100644 index 0000000..20e9ff3 --- /dev/null +++ b/tests/fixtures/python_pipeline/config.yaml @@ -0,0 +1 @@ +foo: bar diff --git a/tests/fixtures/python_pipeline/dup_task_pipeline.py b/tests/fixtures/python_pipeline/dup_task_pipeline.py new file mode 100644 index 0000000..469d462 --- /dev/null +++ b/tests/fixtures/python_pipeline/dup_task_pipeline.py @@ -0,0 +1,14 @@ +"""Two tasks forced to the SAME id via ``.named("Dup")``. + +The emitter walks tasks in trace order and must reject a duplicate task +id explicitly rather than silently dropping the earlier task when the +tasks dict is built. +""" +from tangle_cli.python_pipeline import In, Out, pipeline, ref + + +@pipeline("Dup Task Pipeline") +def dup_task_pipeline(parent_wait_token: In[str], cfg) -> Out[str]: + first = ref(url="file://./noop.yaml").named("Dup")(wait_for=parent_wait_token) + second = ref(url="file://./noop.yaml").named("Dup")(wait_for=parent_wait_token) + return second diff --git a/tests/fixtures/python_pipeline/empty_pipeline.py b/tests/fixtures/python_pipeline/empty_pipeline.py new file mode 100644 index 0000000..97d2d74 --- /dev/null +++ b/tests/fixtures/python_pipeline/empty_pipeline.py @@ -0,0 +1,14 @@ +"""Pipeline whose body calls no ``ref(...)`` — an EMPTY graph. + +The emitter must reject this up front: the dehydrated schema requires at +least one task (``minProperties: 1``). No ``Out[T]`` is declared so the +trace's return check passes and the empty-graph guard in ``emit`` is the +one that fires. +""" +from tangle_cli.python_pipeline import pipeline + + +@pipeline("Empty Pipeline") +def empty_pipeline(cfg): + # No ref(...) calls → zero tasks → CompileError at emit time. + return None diff --git a/tests/fixtures/python_pipeline/multi_arg_pipeline.py b/tests/fixtures/python_pipeline/multi_arg_pipeline.py new file mode 100644 index 0000000..2d295b1 --- /dev/null +++ b/tests/fixtures/python_pipeline/multi_arg_pipeline.py @@ -0,0 +1,37 @@ +"""Multi-argument pipeline fixture exercising every runnable ArgumentValue shape. + +The first task receives several STRING constants (one of them a +``json.dumps`` payload, proving structured data is stringified by the +author, never by tangle-cli), a ``wait_for`` edge bound to an ``In`` +param, and a NON-edge-key argument (``run_mode``) ALSO bound to an ``In`` +param — proving the emitter dispatches purely on the value's type, never +on the argument key. + +The downstream task consumes the upstream task's BARE output via +``depends_on`` and a NAMED output (``produce_data.rows_written``) via a +plain argument key — exercising both ``taskOutput`` shapes and proving +the ``depends_on`` key is preserved verbatim. +""" +import json + +from tangle_cli.python_pipeline import In, Out, pipeline, ref + + +@pipeline("Multi Arg Pipeline") +def multi_arg_pipeline( + parent_wait_token: In[str], + run_mode: In[str], + cfg, +) -> Out[str]: + produce_data = ref(url="file://./noop.yaml")( + a_string="hello world", + a_multiline="line one\nline two\n", + a_json_payload=json.dumps({"mode": "dry_run", "batch_size": 100}), + wait_for=parent_wait_token, + run_mode=run_mode, + ) + consume_data = ref(url="file://./noop.yaml")( + depends_on=produce_data, + rows=produce_data.rows_written, + ) + return consume_data diff --git a/tests/fixtures/python_pipeline/multi_pipeline.py b/tests/fixtures/python_pipeline/multi_pipeline.py new file mode 100644 index 0000000..965c36f --- /dev/null +++ b/tests/fixtures/python_pipeline/multi_pipeline.py @@ -0,0 +1,18 @@ +"""Fixture with TWO ``@pipeline``-decorated functions. + +Used to assert that compile fails cleanly when more than one pipeline is +found in a single file. +""" +from tangle_cli.python_pipeline import In, Out, pipeline, ref + + +@pipeline("First Pipeline") +def first_pipeline(parent_wait_token: In[str], cfg) -> Out[str]: + wait_for_noop = ref(url="file://./noop.yaml")(wait_for=parent_wait_token) + return wait_for_noop + + +@pipeline("Second Pipeline") +def second_pipeline(parent_wait_token: In[str], cfg) -> Out[str]: + wait_for_noop = ref(url="file://./noop.yaml")(wait_for=parent_wait_token) + return wait_for_noop diff --git a/tests/fixtures/python_pipeline/no_pipeline.py b/tests/fixtures/python_pipeline/no_pipeline.py new file mode 100644 index 0000000..96e4905 --- /dev/null +++ b/tests/fixtures/python_pipeline/no_pipeline.py @@ -0,0 +1,8 @@ +"""Fixture with NO ``@pipeline``-decorated function. + +Used to assert that compile fails cleanly when no pipeline is found. +""" +from tangle_cli.python_pipeline import ref + +# A plain ref is not a PipelineFn, so discovery must find zero pipelines. +some_ref = ref(url="file://./noop.yaml") diff --git a/tests/fixtures/python_pipeline/noop.yaml b/tests/fixtures/python_pipeline/noop.yaml new file mode 100644 index 0000000..d0e8567 --- /dev/null +++ b/tests/fixtures/python_pipeline/noop.yaml @@ -0,0 +1,16 @@ +name: Noop +description: Minimal no-op component referenced by compile fixtures via file://./noop.yaml. +inputs: + - name: wait_for + type: String + optional: true +outputs: + - name: wait_for_output + type: String +implementation: + container: + image: python:3.12 + command: + - sh + - -c + - "true" diff --git a/tests/fixtures/python_pipeline/pipeline.py b/tests/fixtures/python_pipeline/pipeline.py new file mode 100644 index 0000000..0aba266 --- /dev/null +++ b/tests/fixtures/python_pipeline/pipeline.py @@ -0,0 +1,13 @@ +"""Minimal Python-authored pipeline fixture for compile tests. + +One task referencing a sibling component YAML by ``file://`` URL, wired +to a single graph input via ``wait_for`` and returned as the pipeline's +``Out[str]`` slot. +""" +from tangle_cli.python_pipeline import In, Out, pipeline, ref + + +@pipeline("Noop Pipeline") +def noop_pipeline(parent_wait_token: In[str], cfg) -> Out[str]: + wait_for_noop = ref(url="file://./noop.yaml")(wait_for=parent_wait_token) + return wait_for_noop diff --git a/tests/fixtures/python_pipeline/task_pipeline.py b/tests/fixtures/python_pipeline/task_pipeline.py new file mode 100644 index 0000000..158c056 --- /dev/null +++ b/tests/fixtures/python_pipeline/task_pipeline.py @@ -0,0 +1,24 @@ +"""Fixture using a ``@task``-decorated component. + +``compile`` emits a sibling ``.components.yaml`` resolver sidecar +with one ``local_from_python`` entry for ``my_task`` and rewrites the +task's componentRef to a pure ``resolve://./.components.yaml#my-task`` +URL. Hydrate regenerates the local component spec from this source file. +""" +from tangle_cli.python_pipeline import In, Out, pipeline, task + + +@task(image="python:3.12") +def my_task(greeting: str = "hello"): + """Write a greeting. + + Metadata: + Name: My Task + """ + print(greeting) + + +@pipeline("Task Pipeline") +def task_pipeline(parent_wait_token: In[str], cfg) -> Out[str]: + run_my_task = my_task(wait_for=parent_wait_token) + return run_my_task diff --git a/tests/test_pipeline_compiler.py b/tests/test_pipeline_compiler.py new file mode 100644 index 0000000..af98b06 --- /dev/null +++ b/tests/test_pipeline_compiler.py @@ -0,0 +1,692 @@ +"""Tests for ``tangle sdk pipelines compile`` (the ported PipelineCompiler). + +Ported from tangle-deploy's ``test_pipeline_compiler.py``. The generic +compile behavior — free functions, dehydrated single-file output, runnable +argument emission, ``@task`` sidecars, and user-facing failure modes — is +exercised directly against ``tangle_cli.pipeline_compiler``. CLI coverage +targets the cyclopts ``compile`` command (``tangle sdk pipelines compile``) +and the ``compile_pipeline_file`` facade rather than tangle-deploy's typer +surface. +""" +import shutil +import sys +from pathlib import Path + +import pytest +import yaml + +from tangle_cli import cli +from tangle_cli.pipeline_compiler import ( + ZONE_ROOT_MARKERS, + CompileResult, + PipelineCompiler, + _parse_overrides, + compile_pipeline, +) +from tangle_cli.pipelines import PipelineValidationError, compile_pipeline_file +from tangle_cli.python_pipeline.errors import CompileError +from tangle_cli.schema_validation import validate_dehydrated_data + +FIXTURES = Path(__file__).parent / "fixtures" / "python_pipeline" + + +def run_app(app, args: list[str]) -> None: + try: + app(args) + except SystemExit as exc: + if exc.code not in (0, None): + raise + + +def _provide_noop(out: Path) -> None: + """Colocate the referenced ``noop.yaml`` component next to the output. + + Fixtures like ``pipeline.py`` / ``multi_arg_pipeline.py`` reference + ``file://./noop.yaml``; the compiler validates that relative local + componentRef targets exist relative to the OUTPUT directory, so the + referenced component must sit next to the compiled YAML. + """ + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(FIXTURES / "noop.yaml", out.parent / "noop.yaml") + + +# --------------------------------------------------------------------------- +# Free-function compile: single dehydrated YAML, result contract, sys hygiene. + + +def test_compile_writes_single_dehydrated_yaml(tmp_path): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + compile_pipeline(FIXTURES / "pipeline.py", out) + + # No wrapper/template sidecars are written. The output dir may legitimately + # also contain the referenced component (noop.yaml) we colocated above. + assert out.exists() + assert not out.with_name("compiled.yaml.j2").exists() + assert not list(tmp_path.glob("*.yaml.j2")) + # This (non-@task) pipeline emits NO components sidecar. + assert not (tmp_path / "compiled.components.yaml").exists() + + data = yaml.safe_load(out.read_text()) + # No wrapper config: no ``template_file`` and no copied cfg keys. + assert "template_file" not in data + assert "foo" not in data + # It is a pipeline body. + assert data["name"] == "Noop Pipeline" + assert "implementation" in data + + tasks = data["implementation"]["graph"]["tasks"] + assert "Wait For Noop" in tasks + task_body = tasks["Wait For Noop"] + + # componentRef is a PURE ref — no inline spec / text. + cref = task_body["componentRef"] + assert cref == {"url": "file://./noop.yaml"} + assert "spec" not in cref + assert "text" not in cref + + # wait_for graph input is emitted as a {graphInput: {inputName}} edge. + assert task_body["arguments"]["wait_for"] == { + "graphInput": {"inputName": "parent_wait_token"} + } + + # The Out[str] return wires to outputValues via {taskOutput: {...}}. + assert data["implementation"]["graph"]["outputValues"]["wait_for_output"] == { + "taskOutput": {"taskId": "Wait For Noop", "outputName": "wait_for_output"} + } + + +def test_compile_pipeline_returns_result(tmp_path): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + result = compile_pipeline(FIXTURES / "pipeline.py", out) + assert isinstance(result, CompileResult) + assert result.pipeline_path == out.resolve() + assert result.components_path is None + assert result.task_count == 1 + assert result.warnings == [] + + +def test_compile_creates_missing_output_parent(tmp_path): + """The normal ``--output`` path is created at write time after validation.""" + out = tmp_path / "nested" / "compiled.yaml" + + result = compile_pipeline(FIXTURES / "task_pipeline.py", out) + + assert result.pipeline_path == out.resolve() + assert out.exists() + assert out.with_name("compiled.components.yaml").exists() + + +def test_compile_pipeline_does_not_leak_sys_state(tmp_path): + """compile_pipeline must not leave residue on sys.path / sys.modules.""" + path_before = list(sys.path) + modules_before = set(sys.modules.keys()) + + out = tmp_path / "compiled.yaml" + _provide_noop(out) + compile_pipeline(FIXTURES / "pipeline.py", out) + + # sys.path is byte-for-byte identical → no fixture dir residue. + assert sys.path == path_before + fixtures_dir = str(FIXTURES.resolve()) + if fixtures_dir not in path_before: + assert fixtures_dir not in sys.path + + # No leftover user-module entry in sys.modules. + new_modules = set(sys.modules.keys()) - modules_before + leaked_user_modules = [ + m for m in new_modules if m.startswith("_tangle_user_pipeline_") + ] + assert leaked_user_modules == [] + + +def test_parse_overrides_rejects_missing_equals(): + assert _parse_overrides(["a=1", "b=two"]) == {"a": "1", "b": "two"} + with pytest.raises(CompileError): + _parse_overrides(["no_equals"]) + + +# --------------------------------------------------------------------------- +# Free-function compile: user-facing failure modes. + + +def test_compile_unresolvable_local_ref_fails(tmp_path): + """A relative file:// componentRef whose target is NOT colocated with + the output fails clearly with guidance, and writes no output.""" + out = tmp_path / "compiled.yaml" + # Deliberately do NOT colocate noop.yaml next to the output. + with pytest.raises(CompileError) as exc: + compile_pipeline(FIXTURES / "pipeline.py", out) + msg = str(exc.value) + assert "file://./noop.yaml" in msg + assert "output directory" in msg + # Guidance for the user. + assert "compile into the pipeline source directory" in msg.lower() + # No output written on failure (neither pipeline nor sidecar). + assert not out.exists() + assert not (tmp_path / "compiled.components.yaml").exists() + + +def test_compile_empty_graph_fails(tmp_path): + """A pipeline whose body calls no ref(...) -> CompileError.""" + out = tmp_path / "compiled.yaml" + with pytest.raises(CompileError) as exc: + compile_pipeline(FIXTURES / "empty_pipeline.py", out) + assert "no tasks" in str(exc.value) + assert not out.exists() + + +def test_compile_duplicate_task_id_fails(tmp_path): + """Two tasks forced to the same id via .named('Dup') -> CompileError.""" + out = tmp_path / "compiled.yaml" + with pytest.raises(CompileError) as exc: + compile_pipeline(FIXTURES / "dup_task_pipeline.py", out) + assert "duplicate task id" in str(exc.value) + assert "Dup" in str(exc.value) + assert not out.exists() + + +def test_compile_rejects_config_output_path_collision(tmp_path): + """If ``@pipeline(config=...)`` points at ``--output``, fail clearly. + + ``config=`` is a compile-time input, not the output file to create. The + compiler should not auto-create the output early and then accidentally + read that empty file as config (or read a stale compiled YAML as config). + """ + out = tmp_path / "compiled.yaml" + src = tmp_path / "self_config_pipeline.py" + src.write_text( + "from tangle_cli.python_pipeline import pipeline\n" + "\n" + "@pipeline('Self Config Pipeline', config='compiled.yaml')\n" + "def self_config_pipeline(cfg):\n" + " pass\n" + ) + + with pytest.raises(CompileError) as exc: + compile_pipeline(src, out) + + msg = str(exc.value) + assert "same path as the --output" in msg + assert "compile-time input config file" in msg + assert "creates the output file automatically" in msg + assert "*.compile_config.yaml" in msg + assert not out.exists() + + +def test_compile_without_cfg_param_ignores_missing_explicit_config(tmp_path): + """A stale ``config=`` decorator does not block compile when unused. + + If the function has no ``cfg`` parameter, the config file is not observable + by the pipeline body. Compile succeeds with a warning instead of requiring + a pointless local config file. + """ + out = tmp_path / "compiled.yaml" + _provide_noop(out) + src = tmp_path / "no_cfg_pipeline.py" + src.write_text( + "from tangle_cli.python_pipeline import In, Out, pipeline, ref\n" + "\n" + "@pipeline('No Cfg Pipeline', config='missing_compile_config.yaml')\n" + "def no_cfg_pipeline(parent_wait_token: In[str]) -> Out[str]:\n" + " wait_for_noop = ref(url='file://./noop.yaml')(wait_for=parent_wait_token)\n" + " return wait_for_noop\n" + ) + + result = compile_pipeline(src, out) + + assert out.exists() + assert result.warnings == [ + "pipeline 'No Cfg Pipeline' declares config='missing_compile_config.yaml' " + "but its function has no `cfg` parameter, so the config file was not " + "loaded. Remove `config=` when no compile-time config is needed, or add " + "a `cfg` parameter to use it." + ] + + +def test_compile_missing_config_with_cfg_param_explains_contract(tmp_path): + """A missing config still fails when the pipeline body accepts ``cfg``.""" + out = tmp_path / "compiled.yaml" + src = tmp_path / "needs_cfg_pipeline.py" + src.write_text( + "from tangle_cli.python_pipeline import In, Out, pipeline, ref\n" + "\n" + "@pipeline('Needs Cfg Pipeline', config='missing_compile_config.yaml')\n" + "def needs_cfg_pipeline(parent_wait_token: In[str], cfg) -> Out[str]:\n" + " wait_for_noop = ref(url='file://./noop.yaml')(wait_for=parent_wait_token)\n" + " return wait_for_noop\n" + ) + + with pytest.raises(CompileError) as exc: + compile_pipeline(src, out) + + msg = str(exc.value) + assert "config file not found" in msg + assert "has a `cfg` parameter" in msg + assert "@pipeline(config='missing_compile_config.yaml')" in msg + assert "compile-time input" in msg + assert "remove the `cfg` parameter" in msg + assert not out.exists() + + +def test_compile_rejects_overrides_without_cfg_param(tmp_path): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + src = tmp_path / "no_cfg_pipeline.py" + src.write_text( + "from tangle_cli.python_pipeline import In, Out, pipeline, ref\n" + "\n" + "@pipeline('No Cfg Pipeline')\n" + "def no_cfg_pipeline(parent_wait_token: In[str]) -> Out[str]:\n" + " wait_for_noop = ref(url='file://./noop.yaml')(wait_for=parent_wait_token)\n" + " return wait_for_noop\n" + ) + + with pytest.raises(CompileError) as exc: + compile_pipeline(src, out, overrides={"mode": "dry_run"}) + + msg = str(exc.value) + assert "received --override values ['mode']" in msg + assert "has no `cfg` parameter" in msg + assert not out.exists() + + +@pytest.mark.parametrize( + "literal, type_name", + [ + ("42", "int"), + ("3.14", "float"), + ("True", "bool"), + ("None", "NoneType"), + ("[1, 2, 'three']", "list"), + ("{'mode': 'dry_run'}", "dict"), + ], +) +def test_compile_non_string_constant_rejected(literal, type_name, tmp_path): + """A non-string constant (int/float/bool/None/list/dict) is rejected at + compile with a generic stringify-guidance error, and NO output is + written. The runnable Tangle argument contract is string-only.""" + out = tmp_path / "compiled.yaml" + _provide_noop(out) + # The pipeline is compiled from its own source dir, where the compiler + # loads /config.yaml by default; provide an empty one. + (tmp_path / "config.yaml").write_text("{}\n") + src = tmp_path / "bad_const_pipeline.py" + src.write_text( + "from tangle_cli.python_pipeline import In, Out, pipeline, ref\n" + "\n" + "@pipeline('Bad Const Pipeline')\n" + "def bad_const_pipeline(parent_wait_token: In[str]) -> Out[str]:\n" + f" run_bad = ref(url='file://./noop.yaml')(bad={literal}, " + "wait_for=parent_wait_token)\n" + " return run_bad\n" + ) + with pytest.raises(CompileError) as exc: + compile_pipeline(src, out) + + msg = str(exc.value) + assert "unsupported constant type" in msg + assert type_name in msg + assert "bad" in msg # names the offending argument + # Carries the stringify guidance and the runnable value contract. + assert "json.dumps" in msg + assert "string constants, graphInput, or taskOutput" in msg + # No partial output is written on failure. + assert not out.exists() + + +# --------------------------------------------------------------------------- +# @task sidecar emission. + + +def test_compile_task_decorator_emits_sidecar(tmp_path): + """@task pipelines compile: the main YAML is written and a + ``.components.yaml`` resolver sidecar is emitted alongside it.""" + out = tmp_path / "compiled.yaml" + result = compile_pipeline(FIXTURES / "task_pipeline.py", out) + assert out.exists() + assert result.components_path == out.with_name("compiled.components.yaml") + assert result.components_path.exists() + # Main pipeline validates as dehydrated (resolve:// URL is valid). + validate_dehydrated_data(yaml.safe_load(out.read_text())) + + +# --------------------------------------------------------------------------- +# Runnable argument-value emission (raw string constant / graphInput / +# taskOutput). Dispatch is on the VALUE's type, never the argument KEY. + + +@pytest.fixture(scope="module") +def multi_arg_args(tmp_path_factory): + """Compiled ``arguments`` block of the multi-arg fixture's two tasks.""" + tmp_path = tmp_path_factory.mktemp("multi_arg") + out = tmp_path / "compiled.yaml" + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(FIXTURES / "noop.yaml", out.parent / "noop.yaml") + compile_pipeline(FIXTURES / "multi_arg_pipeline.py", out) + data = yaml.safe_load(out.read_text()) + tasks = data["implementation"]["graph"]["tasks"] + return data, tasks["Produce Data"]["arguments"], tasks["Consume Data"]["arguments"] + + +def test_compile_string_constants_emit_raw(multi_arg_args): + """Each string constant emits as a RAW string (the runnable Tangle + argument shape) — no ``constantValue`` wrapper. A ``json.dumps`` + payload is just another string constant (the author stringifies + structured data, never tangle-cli).""" + _data, produce, _consume = multi_arg_args + + assert produce["a_string"] == "hello world" + assert produce["a_multiline"] == "line one\nline two\n" + assert produce["a_json_payload"] == '{"mode": "dry_run", "batch_size": 100}' + + # Every constant is a plain string — no wrapper object anywhere. + for value in ( + produce["a_string"], + produce["a_multiline"], + produce["a_json_payload"], + ): + assert isinstance(value, str) + + +def test_compile_graph_input_is_key_independent(multi_arg_args): + """An ``In`` param emits ``graphInput`` regardless of the argument + key — both the edge key ``wait_for`` and the plain key ``run_mode``.""" + _data, produce, _consume = multi_arg_args + + # Edge key. + assert produce["wait_for"] == {"graphInput": {"inputName": "parent_wait_token"}} + + # NON-edge key bound to an In param ALSO emits graphInput (proves the + # emitter dispatches on the value type, not the argument name). + assert produce["run_mode"] == {"graphInput": {"inputName": "run_mode"}} + + +def test_compile_task_outputs_bare_and_named(multi_arg_args): + """Bare task output -> ``outputName: wait_for_output``; attribute + access -> that named output.""" + _data, _produce, consume = multi_arg_args + + # Bare proxy via ``depends_on`` -> canonical done sentinel. + assert consume["depends_on"] == { + "taskOutput": {"taskId": "Produce Data", "outputName": "wait_for_output"} + } + + # ``produce_data.rows_written`` -> named output. + assert consume["rows"] == { + "taskOutput": {"taskId": "Produce Data", "outputName": "rows_written"} + } + + +def test_compile_depends_on_key_preserved_verbatim(multi_arg_args): + """The literal ``depends_on`` key is preserved as the argument key — + only the value is wrapped.""" + _data, _produce, consume = multi_arg_args + assert "depends_on" in consume + # And it was not rewritten to ``wait_for`` or anything else. + assert "wait_for" not in consume + + +def test_compile_validates_schema(multi_arg_args): + """The compiled output passes packaged-schema validation. (compile + already validates internally; this asserts it explicitly too.)""" + data, _produce, _consume = multi_arg_args + # Should not raise. + validate_dehydrated_data(data) + + +# --------------------------------------------------------------------------- +# PipelineCompiler handler + ZONE_ROOT_MARKERS seam. + + +def test_pipeline_compiler_compile_file_returns_result(tmp_path): + """The object-oriented handler drives the same compile and returns the + module-level CompileResult, mirroring PipelineHydrator.""" + out = tmp_path / "compiled.yaml" + _provide_noop(out) + result = PipelineCompiler().compile_file(FIXTURES / "pipeline.py", out) + assert isinstance(result, CompileResult) + assert result.pipeline_path == out.resolve() + assert result.task_count == 1 + + +def test_zone_root_markers_empty_by_default(): + """OSS ships no zone-root markers; downstream distributions extend the + seam. An empty seam means _find_zone_root always returns None.""" + assert ZONE_ROOT_MARKERS == [] + + +# --------------------------------------------------------------------------- +# compile_pipeline_file facade (pipelines.py). + + +def test_compile_pipeline_file_returns_result(tmp_path): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + result = compile_pipeline_file(FIXTURES / "pipeline.py", out) + assert isinstance(result, CompileResult) + assert result.pipeline_path == out.resolve() + assert result.task_count == 1 + + +def test_compile_pipeline_file_wraps_compile_error(tmp_path): + """The facade translates the compiler's CompileError into the CLI's + uniform PipelineValidationError (mirrors hydrate_pipeline_file).""" + out = tmp_path / "compiled.yaml" + # noop.yaml deliberately not colocated -> unresolvable local ref. + with pytest.raises(PipelineValidationError) as exc: + compile_pipeline_file(FIXTURES / "pipeline.py", out) + assert "file://./noop.yaml" in str(exc.value) + assert not out.exists() + + +# --------------------------------------------------------------------------- +# cyclopts `compile` command (tangle sdk pipelines compile). + + +def test_compile_cli_writes_output(tmp_path, capsys): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + app = cli.build_app() + + run_app( + app, + ["sdk", "pipelines", "compile", str(FIXTURES / "pipeline.py"), "-o", str(out)], + ) + + assert out.exists() + assert yaml.safe_load(out.read_text())["name"] == "Noop Pipeline" + assert "Compiled" in capsys.readouterr().out + + +def test_compile_cli_help_exits_zero(capsys): + app = cli.build_app() + run_app(app, ["sdk", "pipelines", "compile", "--help"]) + assert "compile" in capsys.readouterr().out.lower() + + +def test_compile_cli_missing_script_fails(tmp_path): + out = tmp_path / "compiled.yaml" + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(tmp_path / "does_not_exist.py"), + "-o", + str(out), + ] + ) + assert exc_info.value.code != 0 + assert "not found" in str(exc_info.value) + assert not out.exists() + + +def test_compile_cli_no_pipeline_fails(tmp_path): + out = tmp_path / "compiled.yaml" + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "no_pipeline.py"), + "-o", + str(out), + ] + ) + assert exc_info.value.code != 0 + assert "no @pipeline" in str(exc_info.value).lower() + assert not out.exists() + + +def test_compile_cli_multiple_pipelines_fails(tmp_path): + """Without --pipeline, a multi-pipeline file errors with a message that + mentions --pipeline and lists each candidate by function and display name.""" + out = tmp_path / "compiled.yaml" + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "multi_pipeline.py"), + "-o", + str(out), + ] + ) + assert exc_info.value.code != 0 + msg = str(exc_info.value) + assert "multiple" in msg.lower() + assert "--pipeline" in msg + assert "first_pipeline" in msg + assert "second_pipeline" in msg + assert "First Pipeline" in msg + assert "Second Pipeline" in msg + assert not out.exists() + + +def test_compile_cli_select_pipeline_by_function_name(tmp_path): + out = tmp_path / "first.yaml" + _provide_noop(out) + app = cli.build_app() + run_app( + app, + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "multi_pipeline.py"), + "--pipeline", + "first_pipeline", + "-o", + str(out), + ], + ) + assert yaml.safe_load(out.read_text())["name"] == "First Pipeline" + + +def test_compile_cli_select_pipeline_by_display_name(tmp_path): + out = tmp_path / "second.yaml" + _provide_noop(out) + app = cli.build_app() + run_app( + app, + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "multi_pipeline.py"), + "--pipeline", + "Second Pipeline", + "-o", + str(out), + ], + ) + assert yaml.safe_load(out.read_text())["name"] == "Second Pipeline" + + +def test_compile_cli_unknown_pipeline_name_fails(tmp_path): + out = tmp_path / "compiled.yaml" + _provide_noop(out) + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "multi_pipeline.py"), + "--pipeline", + "nope", + "-o", + str(out), + ] + ) + assert exc_info.value.code != 0 + assert "not found" in str(exc_info.value).lower() + assert "first_pipeline" in str(exc_info.value) + assert not out.exists() + + +def test_compile_cli_bad_override_format_fails(tmp_path): + out = tmp_path / "compiled.yaml" + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "pipeline.py"), + "-o", + str(out), + "--override", + "no_equals", + ] + ) + assert exc_info.value.code != 0 + assert "override" in str(exc_info.value).lower() + assert not out.exists() + + +def test_compile_cli_unresolvable_local_ref_exit_nonzero(tmp_path): + out = tmp_path / "compiled.yaml" + app = cli.build_app() + with pytest.raises(SystemExit) as exc_info: + app( + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "pipeline.py"), + "-o", + str(out), + ] + ) + assert exc_info.value.code != 0 + assert "noop.yaml" in str(exc_info.value) + assert not out.exists() + + +def test_compile_cli_task_decorator_exit_zero(tmp_path): + out = tmp_path / "compiled.yaml" + app = cli.build_app() + run_app( + app, + [ + "sdk", + "pipelines", + "compile", + str(FIXTURES / "task_pipeline.py"), + "-o", + str(out), + ], + ) + assert out.exists() + assert out.with_name("compiled.components.yaml").exists() diff --git a/tests/test_pipelines_cli.py b/tests/test_pipelines_cli.py index 90bb633..7f865f9 100644 --- a/tests/test_pipelines_cli.py +++ b/tests/test_pipelines_cli.py @@ -82,8 +82,8 @@ def test_sdk_pipelines_help_lists_local_commands(capsys): assert "hydrate" in output assert "diagram" in output assert "layout" in output + assert "compile" in output assert "pipeline-runs" not in output - assert "compile" not in output def test_pipeline_hydrator_generic_local_resolver_priority(tmp_path: Path): diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py new file mode 100644 index 0000000..536a574 --- /dev/null +++ b/tests/test_schema_validation.py @@ -0,0 +1,233 @@ +"""Tests for the ported ``tangle_cli.schema_validation`` module. + +Covers the packaged dehydrated-schema loader, JSON-Schema structural +validation, the no-template-delimiter output contract (including +``exempt_paths``), the dehydrated shape detector, and the semantic checks +layered on top of the schema (dangling ``taskOutput.taskId``, undeclared +``graphInput.inputName``, ``outputValues`` ↔ ``outputs`` correspondence, +and the top-level key / ``template_file`` guards). +""" +import copy + +import pytest + +from tangle_cli.schema_validation import ( + SchemaValidationError, + assert_no_template_delimiters, + is_dehydrated_pipeline, + iter_template_delimiters, + load_dehydrated_schema, + validate_dehydrated_data, + validate_dehydrated_pipeline, +) + + +def _valid_pipeline() -> dict: + """A minimal schema-valid, semantically-consistent dehydrated pipeline.""" + return { + "name": "Demo Pipeline", + "inputs": [{"name": "in1", "type": "String"}], + "outputs": [{"name": "out1", "type": "String"}], + "implementation": { + "graph": { + "tasks": { + "extract": { + "componentRef": {"url": "file://./noop.yaml"}, + "arguments": { + "wait_for": {"graphInput": {"inputName": "in1"}}, + }, + }, + "load": { + "componentRef": {"name": "Load"}, + "arguments": { + "rows": { + "taskOutput": { + "taskId": "extract", + "outputName": "rows", + } + }, + "a_constant": "hello world", + }, + }, + }, + "outputValues": { + "out1": { + "taskOutput": {"taskId": "load", "outputName": "result"} + }, + }, + } + }, + } + + +# --------------------------------------------------------------------------- +# Schema loading + structural validation. + + +def test_load_dehydrated_schema_has_expected_identity(): + schema = load_dehydrated_schema() + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert schema["$id"] == ( + "https://tangleml.com/schemas/tangle/dehydrated_pipeline_schema.json" + ) + # Loader is cached: same object every call. + assert load_dehydrated_schema() is schema + + +def test_validate_dehydrated_data_accepts_valid(): + validate_dehydrated_data(_valid_pipeline()) # should not raise + + +def test_validate_dehydrated_data_rejects_inline_component_spec(): + data = _valid_pipeline() + data["implementation"]["graph"]["tasks"]["extract"]["componentRef"] = { + "spec": {"name": "Extract"} + } + with pytest.raises(SchemaValidationError): + validate_dehydrated_data(data) + + +def test_validate_dehydrated_data_rejects_missing_name(): + data = _valid_pipeline() + del data["name"] + with pytest.raises(SchemaValidationError): + validate_dehydrated_data(data) + + +def test_validate_dehydrated_data_rejects_empty_tasks(): + data = _valid_pipeline() + data["implementation"]["graph"]["tasks"] = {} + with pytest.raises(SchemaValidationError): + validate_dehydrated_data(data) + + +# --------------------------------------------------------------------------- +# Template-delimiter output contract. + + +def test_iter_template_delimiters_finds_offenders(): + data = {"name": "x", "sql": "SELECT {{ value }}"} + found = dict(iter_template_delimiters(data)) + assert "sql" in found + assert found["sql"] == "{{" + + +def test_assert_no_template_delimiters_passes_clean(): + assert_no_template_delimiters(_valid_pipeline()) # should not raise + + +def test_assert_no_template_delimiters_flags_leaked_template(): + data = _valid_pipeline() + data["implementation"]["graph"]["tasks"]["load"]["arguments"][ + "a_constant" + ] = "{{ not_rendered }}" + with pytest.raises(SchemaValidationError) as exc: + assert_no_template_delimiters(data) + assert "{{" in str(exc.value) + + +def test_assert_no_template_delimiters_honors_exempt_paths(): + data = _valid_pipeline() + args = data["implementation"]["graph"]["tasks"]["load"]["arguments"] + args["a_constant"] = "{{input_1}}" + path = "implementation.graph.tasks.load.arguments.a_constant" + # Sanity: the offender is at the path we expect. + assert (path, "{{") in list(iter_template_delimiters(data)) + # Exempting that exact path is accepted; a wrong path is not. + assert_no_template_delimiters(data, exempt_paths=[path]) + with pytest.raises(SchemaValidationError): + assert_no_template_delimiters(data, exempt_paths=["some.other.path"]) + + +# --------------------------------------------------------------------------- +# Shape detection. + + +def test_is_dehydrated_pipeline_true_for_valid(): + assert is_dehydrated_pipeline(_valid_pipeline()) is True + + +def test_is_dehydrated_pipeline_false_for_template_file(): + data = _valid_pipeline() + data["template_file"] = "pipeline.yaml.j2" + assert is_dehydrated_pipeline(data) is False + + +def test_is_dehydrated_pipeline_false_for_non_runnable_argument(): + data = _valid_pipeline() + # A bare int is not a runnable argument value (string / graphInput / + # taskOutput) -> not yet dehydrated. + data["implementation"]["graph"]["tasks"]["load"]["arguments"]["rows"] = 42 + assert is_dehydrated_pipeline(data) is False + + +def test_is_dehydrated_pipeline_false_for_non_mapping(): + assert is_dehydrated_pipeline(["not", "a", "pipeline"]) is False + + +# --------------------------------------------------------------------------- +# Full validate_dehydrated_pipeline: guards + semantics. + + +def test_validate_dehydrated_pipeline_accepts_valid(): + validate_dehydrated_pipeline(_valid_pipeline()) # should not raise + + +def test_validate_dehydrated_pipeline_rejects_template_file(): + data = _valid_pipeline() + data["template_file"] = "pipeline.yaml.j2" + with pytest.raises(SchemaValidationError) as exc: + validate_dehydrated_pipeline(data) + assert "template_file" in str(exc.value) + + +def test_validate_dehydrated_pipeline_rejects_extra_top_level_key(): + data = _valid_pipeline() + data["foo"] = "leaked config" + with pytest.raises(SchemaValidationError) as exc: + validate_dehydrated_pipeline(data) + assert "foo" in str(exc.value) + + +def test_validate_dehydrated_pipeline_rejects_dangling_task_output(): + data = _valid_pipeline() + data["implementation"]["graph"]["tasks"]["load"]["arguments"]["rows"] = { + "taskOutput": {"taskId": "missing", "outputName": "rows"} + } + with pytest.raises(SchemaValidationError) as exc: + validate_dehydrated_pipeline(data) + assert "missing" in str(exc.value) + + +def test_validate_dehydrated_pipeline_rejects_undeclared_graph_input(): + data = _valid_pipeline() + data["implementation"]["graph"]["tasks"]["extract"]["arguments"][ + "wait_for" + ] = {"graphInput": {"inputName": "nope"}} + with pytest.raises(SchemaValidationError) as exc: + validate_dehydrated_pipeline(data) + assert "nope" in str(exc.value) + + +def test_validate_dehydrated_pipeline_rejects_output_value_without_output(): + data = _valid_pipeline() + data["implementation"]["graph"]["outputValues"]["ghost"] = { + "taskOutput": {"taskId": "load", "outputName": "result"} + } + with pytest.raises(SchemaValidationError) as exc: + validate_dehydrated_pipeline(data) + assert "ghost" in str(exc.value) + + +def test_validate_dehydrated_pipeline_rejects_non_scalar_annotation(): + data = _valid_pipeline() + data["metadata"] = {"annotations": {"owner": {"nested": "object"}}} + with pytest.raises(SchemaValidationError): + validate_dehydrated_pipeline(data) + + +def test_validate_dehydrated_pipeline_does_not_mutate_input(): + data = _valid_pipeline() + snapshot = copy.deepcopy(data) + validate_dehydrated_pipeline(data) + assert data == snapshot diff --git a/uv.lock b/uv.lock index ca1d97b..3610057 100644 --- a/uv.lock +++ b/uv.lock @@ -4,11 +4,12 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", ] [options] -exclude-newer = "2026-06-29T15:21:31.720493Z" +exclude-newer = "2026-06-30T18:27:37.768162Z" exclude-newer-span = "P7D" [manifest] @@ -814,6 +815,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kubernetes" version = "35.0.0" @@ -1473,6 +1502,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1662,6 +1706,259 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "sentry-sdk" version = "2.61.1" @@ -1794,6 +2091,7 @@ dependencies = [ { name = "docstring-parser" }, { name = "httpx" }, { name = "jinja2" }, + { name = "jsonschema" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -1829,6 +2127,7 @@ requires-dist = [ { name = "docstring-parser", specifier = ">=0.16" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "jsonschema", specifier = ">=4.0.0" }, { name = "platformdirs", specifier = ">=4.10.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" },