From c9f0f2d31b5de44e5937468031dc1910e6ad1db4 Mon Sep 17 00:00:00 2001 From: Silin Gupta Date: Fri, 17 Jul 2026 09:57:11 -0400 Subject: [PATCH 1/2] feat(pipeline-run): fall back to file stem for a nameless submit spec A file-based submit whose spec omits a usable top-level `name` is now resolved to the source-file stem before submit-time validation runs, rather than being rejected outright. This restores the long-standing downstream behavior where `submit my_pipeline.yaml` runs under the name `my_pipeline` when the spec itself declares no name. The fallback is applied at the single shared submit chokepoint (`PipelineRunManager.prepare_submit_payload_from_spec`) and in the higher-level `PipelineRunner.prepare_pipeline_for_run` (which validates first in the full run lifecycle), via a small shared helper `_resolve_fallback_pipeline_name`. A declared non-empty `name` always wins; only the genuinely-nameless case is filled, so specs that are malformed for any other reason (missing/!object `implementation`, etc.) still fail validation exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tangle_cli/pipeline_run_manager.py | 27 ++++++++++++ .../src/tangle_cli/pipeline_runner.py | 8 ++++ tests/test_pipeline_runs_cli.py | 44 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index 4183687..e13fcd0 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -926,6 +926,29 @@ def prepare_pipeline_spec_for_submit( hydrate=hydrate, ) + @staticmethod + def _resolve_fallback_pipeline_name( + pipeline_spec: dict[str, Any], + fallback_name: str | None, + ) -> dict[str, Any]: + """Fill a missing pipeline ``name`` from a caller-resolved fallback. + + A file-based submit whose spec omits a usable ``name`` still validates + and runs under a fallback (historically the source-file stem), matching + the long-standing downstream behavior. A declared non-empty ``name`` + always wins, and the fallback is applied only to the genuinely-nameless + case, so other structural errors still surface at validation. A no-op + when the spec is not a mapping or no fallback name is available. + """ + if not isinstance(pipeline_spec, dict): + return pipeline_spec + name = pipeline_spec.get("name") + if isinstance(name, str) and name: + return pipeline_spec + if not (isinstance(fallback_name, str) and fallback_name): + return pipeline_spec + return {**pipeline_spec, "name": fallback_name} + def prepare_submit_payload_from_spec( self, pipeline_spec: dict[str, Any], @@ -954,6 +977,10 @@ def prepare_submit_payload_from_spec( ) prepared_run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args) prepared_spec = self.apply_run_name_template(prepared_spec, prepared_run_args) + prepared_spec = self._resolve_fallback_pipeline_name( + prepared_spec, + Path(str(pipeline_path)).stem if pipeline_path is not None else None, + ) if not skip_validation: validation_errors = self.hooks.validate_pipeline_for_run( prepared_spec, diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py index 6e7926d..3132cec 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_runner.py @@ -391,6 +391,14 @@ def prepare_pipeline_for_run( if isinstance(spec_name, str) and spec_name: pipeline_name = spec_name + # Resolve the fallback run name into the spec before validation. + # ``pipeline_name`` has already fallen back to ``initial_pipeline_name`` + # (the path stem) whenever the spec omits a usable ``name``; mirror + # that into the spec so a file-based submit with no declared name + # still validates and runs under the fallback. A declared name has + # already won above, so only the genuinely-nameless case is filled. + pipeline_spec = self._resolve_fallback_pipeline_name(pipeline_spec, pipeline_name) + validation_errors = hooks.validate_pipeline_for_run( pipeline_spec, pipeline_path=pipeline_path, diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index b65d7d5..72f19a5 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -203,6 +203,50 @@ def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, assert root_task["arguments"] == {"query": "default", "required": "value"} +def test_pipeline_runs_submit_falls_back_to_file_stem_when_spec_has_no_name( + monkeypatch, tmp_path: Path, capsys +): + # A file-based submit whose spec omits ``name`` still validates and runs, + # taking the source-file stem as the run name (the long-standing downstream + # fallback, now honored natively in OSS rather than rejected at submit). + pipeline_path = tmp_path / "my_pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump({"implementation": {"graph": {"tasks": {}}}}, sort_keys=False), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--no-hydrate"]) + + result = json.loads(capsys.readouterr().out) + assert result == {"id": "run-1", "root_execution_id": "exec-1"} + assert fake_client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "my_pipeline" + + +def test_pipeline_runs_submit_prefers_declared_name_over_file_stem( + monkeypatch, tmp_path: Path, capsys +): + # A declared non-empty ``name`` always wins over the file-stem fallback, + # even when the file stem (here ``config``) differs from the declared name. + pipeline_path = tmp_path / "config.yaml" + pipeline_path.write_text( + yaml.safe_dump( + {"name": "Demo Pipeline", "implementation": {"graph": {"tasks": {}}}}, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--no-hydrate"]) + + assert fake_client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" + + def test_pipeline_runs_submit_rejects_authoring_validation_errors(monkeypatch, tmp_path: Path): pipeline_path = _write_invalid_authoring_pipeline(tmp_path / "pipeline.yaml") fake_client = FakeClient() From 45353fc7ab8155e395bcdc36b8bba04f5146497b Mon Sep 17 00:00:00 2001 From: Silin Gupta Date: Sat, 18 Jul 2026 11:20:31 -0400 Subject: [PATCH 2/2] Treat a whitespace-only name as nameless for stem fallback Address review nit: `isinstance(name, str) and name` accepted a whitespace-only `name` (e.g. " ") as a declared name, skipping the file-stem fallback and submitting a blank run name. Switch to `name.strip()` so a blank-ish name is treated as genuinely nameless and takes the stem, matching the "non-empty name" intent. Add a regression test covering the whitespace-only case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tangle_cli/pipeline_run_manager.py | 11 +++++---- tests/test_pipeline_runs_cli.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py index e13fcd0..24742fa 100644 --- a/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +++ b/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py @@ -935,15 +935,16 @@ def _resolve_fallback_pipeline_name( A file-based submit whose spec omits a usable ``name`` still validates and runs under a fallback (historically the source-file stem), matching - the long-standing downstream behavior. A declared non-empty ``name`` - always wins, and the fallback is applied only to the genuinely-nameless - case, so other structural errors still surface at validation. A no-op - when the spec is not a mapping or no fallback name is available. + the long-standing downstream behavior. A declared non-blank ``name`` + always wins; a missing, non-string, or whitespace-only ``name`` is + treated as genuinely nameless and takes the fallback, so other + structural errors still surface at validation. A no-op when the spec is + not a mapping or no fallback name is available. """ if not isinstance(pipeline_spec, dict): return pipeline_spec name = pipeline_spec.get("name") - if isinstance(name, str) and name: + if isinstance(name, str) and name.strip(): return pipeline_spec if not (isinstance(fallback_name, str) and fallback_name): return pipeline_spec diff --git a/tests/test_pipeline_runs_cli.py b/tests/test_pipeline_runs_cli.py index 72f19a5..b74b0ad 100644 --- a/tests/test_pipeline_runs_cli.py +++ b/tests/test_pipeline_runs_cli.py @@ -247,6 +247,29 @@ def test_pipeline_runs_submit_prefers_declared_name_over_file_stem( assert fake_client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "Demo Pipeline" +def test_pipeline_runs_submit_falls_back_to_file_stem_for_whitespace_only_name( + monkeypatch, tmp_path: Path, capsys +): + # A whitespace-only ``name`` is not a usable declared name, so it is treated + # as nameless and takes the file-stem fallback rather than being submitted + # verbatim as a blank run name. + pipeline_path = tmp_path / "my_pipeline.yaml" + pipeline_path.write_text( + yaml.safe_dump( + {"name": " ", "implementation": {"graph": {"tasks": {}}}}, + sort_keys=False, + ), + encoding="utf-8", + ) + fake_client = FakeClient() + monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: fake_client) + app = cli.build_app() + + run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--no-hydrate"]) + + assert fake_client.created[0]["root_task"]["componentRef"]["spec"]["name"] == "my_pipeline" + + def test_pipeline_runs_submit_rejects_authoring_validation_errors(monkeypatch, tmp_path: Path): pipeline_path = _write_invalid_authoring_pipeline(tmp_path / "pipeline.yaml") fake_client = FakeClient()