Skip to content

Commit ae32ea2

Browse files
committed
feat: validate pipeline specs during submit
Run the full pipelines validate authoring validator during pipeline-runs submit and --dry-run after hydration, and route PipelineRunner through the same base hook. Root graph requirements are enforced by the authoring validator, with README and tests updated. Bump tangle-cli patch version to 0.1.3. This is based on master and intentionally excludes PR TangleML#27 skip-validation CLI flag and separate submit-root check. Assisted-By: devx/5490f7cb-c7e4-4cf5-b525-a8c4e2af55b1
1 parent 9c74071 commit ae32ea2

10 files changed

Lines changed: 319 additions & 27 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ uv run tangle sdk pipeline-runs export RUN_ID --output pipeline.yaml
293293

294294
`submit` hydrates refs by default and builds an API submit payload with `root_task.componentRef.spec`. Use `--no-hydrate` to submit the local YAML structure as-is. Use `--dry-run` to print the payload without creating a run.
295295

296+
Before creating a run—or printing a `--dry-run` payload—`submit` runs the same authoring validation as `tangle sdk pipelines validate` on the hydrated/resolved pipeline spec (or on the as-is spec when `--no-hydrate` is used). Invalid specs fail locally with `Pipeline validation failed` errors before the run-submission API call. For example, the pipeline root must be a graph (`implementation.graph`), so a bare `implementation.container` root is rejected before the run is submitted.
297+
296298
## Programmatic client
297299

298300
The stable public wrapper for downstream Python tools is:

packages/tangle-cli/src/tangle_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
try:
1515
__version__ = metadata_version("tangle-cli")
1616
except PackageNotFoundError:
17-
__version__ = "0.1.1"
17+
__version__ = "0.1.3"
1818

1919
__all__ = ["TangleDynamicDiscoveryClient", "__version__"]

packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .pipeline_dehydrator import DehydrateChoice, PipelineDehydrator
2929
from .pipeline_hydrator import HydrationError, PipelineHydrator
3030
from .pipeline_run_details import PipelineRunDetails
31+
from .pipelines import collect_pipeline_spec_errors
3132
from .pipeline_run_search import PipelineRunSearch
3233
from .utils import dump_yaml
3334

@@ -378,6 +379,26 @@ def prepare_run_arguments(
378379
"""Hook for TD JOB_CONFIG time input / scheduled runtime behavior."""
379380
return run_args
380381

382+
def validate_pipeline_for_run(
383+
self,
384+
pipeline_spec: dict[str, Any],
385+
*,
386+
pipeline_path: str | Path | None,
387+
effective_path: str | Path | None,
388+
skip_validation: bool,
389+
) -> list[str]:
390+
"""Return submit-time validation errors for a prepared pipeline spec.
391+
392+
The OSS default enforces the same local authoring validator used by
393+
``tangle pipeline validate``. Downstreams can override or extend this
394+
hook with stricter schema/input validators.
395+
"""
396+
397+
del pipeline_path, effective_path
398+
if skip_validation:
399+
return []
400+
return collect_pipeline_spec_errors(pipeline_spec)
401+
381402
def transform_run_name(
382403
self,
383404
run_name: str,
@@ -834,6 +855,11 @@ def _accepts_client_keyword(method: Any) -> bool:
834855
for parameter in parameters.values()
835856
)
836857

858+
@staticmethod
859+
def _raise_pipeline_validation_error(validation_errors: list[str]) -> None:
860+
if validation_errors:
861+
raise PipelineRunError("Pipeline validation failed:\n - " + "\n - ".join(validation_errors))
862+
837863
def load_pipeline_for_submit(
838864
self,
839865
pipeline_path: str | Path,
@@ -909,13 +935,15 @@ def prepare_submit_payload_from_spec(
909935
pipeline_path: str | Path | None = None,
910936
run_as: str | None = None,
911937
hydrate: bool = True,
938+
skip_validation: bool = False,
912939
) -> PipelineSubmitPayload:
913940
"""Prepare the generic submit payload from a pipeline spec.
914941
915942
The order here is the submit-body contract shared by OSS and TD:
916943
prepare the spec, prepare runtime arguments, expand run-name templates,
917-
convert/sanitize the payload, then merge downstream/default annotations
918-
before caller-supplied annotations override them.
944+
validate the prepared authoring spec, convert/sanitize the payload, then
945+
merge downstream/default annotations before caller-supplied annotations
946+
override them.
919947
"""
920948

921949
prepared_spec = self.prepare_pipeline_spec_for_submit(
@@ -926,6 +954,13 @@ def prepare_submit_payload_from_spec(
926954
)
927955
prepared_run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args)
928956
prepared_spec = self.apply_run_name_template(prepared_spec, prepared_run_args)
957+
validation_errors = self.hooks.validate_pipeline_for_run(
958+
prepared_spec,
959+
pipeline_path=pipeline_path,
960+
effective_path=None,
961+
skip_validation=skip_validation,
962+
)
963+
self._raise_pipeline_validation_error(validation_errors)
929964
payload = self.convert_yaml_to_payload(copy.deepcopy(prepared_spec), prepared_run_args)
930965
payload = self.sanitize_submit_payload(payload)
931966
root_task = payload["root_task"]
@@ -961,6 +996,7 @@ def build_submit_body_from_spec(
961996
pipeline_path: str | Path | None = None,
962997
run_as: str | None = None,
963998
hydrate: bool = True,
999+
skip_validation: bool = False,
9641000
) -> dict[str, Any]:
9651001
"""Build a submit body from an already-prepared pipeline spec."""
9661002

@@ -971,6 +1007,7 @@ def build_submit_body_from_spec(
9711007
pipeline_path=pipeline_path,
9721008
run_as=run_as,
9731009
hydrate=hydrate,
1010+
skip_validation=skip_validation,
9741011
).to_body()
9751012

9761013
def prepare_submit_payload(
@@ -982,6 +1019,7 @@ def prepare_submit_payload(
9821019
hydrate: bool = True,
9831020
run_as: str | None = None,
9841021
resolution_overrides: dict[str, Any] | None = None,
1022+
skip_validation: bool = False,
9851023
) -> PipelineSubmitPayload:
9861024
pipeline_spec = self.load_pipeline_for_submit(
9871025
pipeline_path,
@@ -995,6 +1033,7 @@ def prepare_submit_payload(
9951033
pipeline_path=pipeline_path,
9961034
run_as=run_as,
9971035
hydrate=hydrate,
1036+
skip_validation=skip_validation,
9981037
)
9991038

10001039
def build_submit_body(
@@ -1006,6 +1045,7 @@ def build_submit_body(
10061045
hydrate: bool = True,
10071046
run_as: str | None = None,
10081047
resolution_overrides: dict[str, Any] | None = None,
1048+
skip_validation: bool = False,
10091049
) -> dict[str, Any]:
10101050
return self.prepare_submit_payload(
10111051
pipeline_path,
@@ -1014,6 +1054,7 @@ def build_submit_body(
10141054
hydrate=hydrate,
10151055
run_as=run_as,
10161056
resolution_overrides=resolution_overrides,
1057+
skip_validation=skip_validation,
10171058
).to_body()
10181059

10191060
@staticmethod
@@ -1117,6 +1158,7 @@ def submit_pipeline_spec(
11171158
run_as: str | None = None,
11181159
hydrate: bool = True,
11191160
attempt: int = 1,
1161+
skip_validation: bool = False,
11201162
) -> dict[str, Any]:
11211163
payload = self.prepare_submit_payload_from_spec(
11221164
pipeline_spec,
@@ -1125,6 +1167,7 @@ def submit_pipeline_spec(
11251167
pipeline_path=pipeline_path,
11261168
run_as=run_as,
11271169
hydrate=hydrate,
1170+
skip_validation=skip_validation,
11281171
)
11291172
return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt)
11301173

@@ -1138,6 +1181,7 @@ def submit_pipeline(
11381181
run_as: str | None = None,
11391182
resolution_overrides: dict[str, Any] | None = None,
11401183
attempt: int = 1,
1184+
skip_validation: bool = False,
11411185
) -> dict[str, Any]:
11421186
payload = self.prepare_submit_payload(
11431187
pipeline_path,
@@ -1146,6 +1190,7 @@ def submit_pipeline(
11461190
hydrate=hydrate,
11471191
run_as=run_as,
11481192
resolution_overrides=resolution_overrides,
1193+
skip_validation=skip_validation,
11491194
)
11501195
return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt)
11511196

@@ -1909,6 +1954,7 @@ def run_pipeline_spec(
19091954
exit_on_first_failure: bool = False,
19101955
metadata: dict[str, Any] | None = None,
19111956
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
1957+
skip_validation: bool = False,
19121958
) -> dict[str, Any]:
19131959
"""Submit/wait/retry an already hydrated/validated in-memory spec."""
19141960

@@ -1924,6 +1970,7 @@ def body_factory(
19241970
pipeline_path=pipeline_path,
19251971
run_as=run_as,
19261972
hydrate=hydrate,
1973+
skip_validation=skip_validation,
19271974
).to_body()
19281975

19291976
return self._run_body_factory(
@@ -1960,6 +2007,7 @@ def run_pipeline(
19602007
exit_on_first_failure: bool = False,
19612008
metadata: dict[str, Any] | None = None,
19622009
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
2010+
skip_validation: bool = False,
19632011
) -> dict[str, Any]:
19642012
"""Submit (and optionally wait for) a pipeline with lifecycle hooks.
19652013
@@ -1980,6 +2028,7 @@ def body_factory(
19802028
hydrate=hydrate,
19812029
run_as=run_as,
19822030
resolution_overrides=resolution_overrides,
2031+
skip_validation=skip_validation,
19832032
).to_body()
19842033

19852034
return self._run_body_factory(

packages/tangle-cli/src/tangle_cli/pipeline_runner.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -109,24 +109,6 @@ def prepare_loaded_pipeline_spec(
109109

110110
return pipeline_spec
111111

112-
def validate_pipeline_for_run(
113-
self,
114-
pipeline_spec: dict[str, Any],
115-
*,
116-
pipeline_path: str | Path,
117-
effective_path: str | Path | None,
118-
skip_validation: bool,
119-
) -> list[str]:
120-
"""Return validation errors for a prepared pipeline spec.
121-
122-
The OSS default intentionally does not enforce the local authoring
123-
validator here: submit-time API validation remains the source of truth,
124-
while downstreams can plug in stricter schema/input validators.
125-
"""
126-
127-
del pipeline_spec, pipeline_path, effective_path, skip_validation
128-
return []
129-
130112
def has_layout(self, pipeline_spec: Mapping[str, Any]) -> bool:
131113
"""Return True when a pipeline graph already has non-zero coordinates."""
132114

@@ -547,6 +529,7 @@ def body_factory(
547529
pipeline_path=pipeline_path,
548530
run_as=run_as,
549531
hydrate=False,
532+
skip_validation=skip_validation,
550533
)
551534
submit_payloads[attempt] = submit_payload
552535
return submit_payload.to_body()

packages/tangle-cli/src/tangle_cli/pipelines.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ def validate_pipeline_file(path: str | Path) -> dict[str, Any]:
8989
return pipeline
9090

9191

92-
def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
93-
"""Validate the OSS-compatible local pipeline shape.
92+
def collect_pipeline_spec_errors(pipeline: Mapping[str, Any]) -> list[str]:
93+
"""Return OSS-compatible local pipeline shape validation errors.
9494
9595
This is a pragmatic validator for local authoring workflows. It focuses on
9696
the graph structure that the CLI commands consume rather than provider-specific
@@ -99,6 +99,13 @@ def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
9999

100100
errors: list[str] = []
101101
_validate_root_pipeline(pipeline, errors)
102+
return errors
103+
104+
105+
def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
106+
"""Validate the OSS-compatible local pipeline shape."""
107+
108+
errors = collect_pipeline_spec_errors(pipeline)
102109
if errors:
103110
details = "\n".join(f"- {error}" for error in errors)
104111
raise PipelineValidationError(f"Pipeline validation failed:\n{details}")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tangle-cli"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
55
readme = "README.md"
66
authors = [

tests/test_packaging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api
178178
requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")]
179179
assert not any(name.startswith("tangle_api/") for name in names)
180180
assert "tangle_cli/openapi/openapi.json" not in names
181-
assert "Version: 0.1.2" in metadata
181+
assert "Version: 0.1.3" in metadata
182182
assert "Requires-Dist: tangle-api==0.1.1" in requires_dist
183183
assert not any("extra == 'native'" in line for line in requires_dist)
184184
assert "Provides-Extra: native" in metadata

0 commit comments

Comments
 (0)