-
Notifications
You must be signed in to change notification settings - Fork 5
feat(python-pipeline): support dynamic_secret runtime-secret arguments #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| """Dynamic-data argument helpers for Python-authored pipelines. | ||
|
|
||
| Tangle runnable arguments can be literals, graph/task edges, or dynamic data | ||
| resolved by the runtime (for example a secret reference). The Python pipeline | ||
| emitter needs an explicit wrapper so author code can request a dynamicData | ||
| argument without opening support for arbitrary dict constants. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Mapping | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class DynamicData: | ||
| """A task argument value emitted as ``{"dynamicData": value}``. | ||
|
|
||
| Internal emit primitive — not part of the public authoring surface (see | ||
| ``__init__.__all__``). ``value`` is a general mapping because the runtime | ||
| resolves several dynamic-data kinds (secrets, run IDs, loop indices), but | ||
| the only public producer today is :func:`dynamic_secret`, which always | ||
| builds the ``{"secret": {"name": ...}}`` shape that the strict dehydrated | ||
| schema accepts. Any other shape emits but fails closed at compile | ||
| validation until a new kind is added to both the constructor surface and | ||
| the schema together — so the public API never promises more than the | ||
| schema enforces. | ||
| """ | ||
|
|
||
| value: Mapping[str, Any] | ||
|
Comment on lines
+14
to
+29
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (AI-assisted) [nit] Minor also:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — fixed in 4949b4a. Rather than loosen the strict schema, I documented On the hash: since it holds a dict and is never hashed, I left it unhashable rather than fabricate a canonical form — happy to add a |
||
|
|
||
|
|
||
| def dynamic_secret(name: str) -> DynamicData: | ||
| """Reference a runtime secret by name for a task argument. | ||
|
|
||
| Example emitted YAML:: | ||
|
|
||
| openai_api_key: | ||
| dynamicData: | ||
| secret: | ||
| name: OPENAI_API_KEY | ||
| """ | ||
| if not isinstance(name, str) or not name: | ||
| raise ValueError("dynamic_secret() requires a non-empty secret name string") | ||
| return DynamicData({"secret": {"name": name}}) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| """Minimal compile fixture for a task argument sourced from a runtime secret.""" | ||
|
|
||
| from tangle_cli.python_pipeline import dynamic_secret, pipeline, ref | ||
|
|
||
| MODEL = ref(name="model") | ||
|
|
||
|
|
||
| @pipeline(name="Dynamic Secret") | ||
| def dynamic_secret_pipeline() -> None: | ||
| MODEL.named("Call Model")(openai_api_key=dynamic_secret("OPENAI_API_KEY")) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Tests for ``dynamicData`` argument wrappers in Python-authored pipelines. | ||
|
|
||
| The dehydrated-pipeline schema is deliberately strict: a ``dynamicData`` | ||
| argument must be a non-empty ``secret.name`` reference and nothing else. | ||
| (The runnable ``pipeline_schema.json`` stays intentionally permissive — it | ||
| mirrors the real Tangle runtime, which resolves arbitrary dynamic data such | ||
| as run IDs and loop indices — so strictness is enforced here, at compile / | ||
| dehydrated-validation time.) | ||
| """ | ||
| import copy | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
| import yaml | ||
|
|
||
| from tangle_cli.pipeline_compiler import compile_pipeline | ||
| from tangle_cli.python_pipeline.dynamic_data import dynamic_secret | ||
| from tangle_cli.python_pipeline.emit import emit_pipeline | ||
| from tangle_cli.python_pipeline.graph import GraphBuilder, TaskNode | ||
| from tangle_cli.schema_validation import SchemaValidationError, validate_dehydrated_pipeline | ||
|
|
||
| FIXTURES = Path(__file__).parent / "fixtures" / "python_pipeline" | ||
|
|
||
|
|
||
| def _body_with_dynamic_secret() -> dict: | ||
| builder = GraphBuilder(name="Demo") | ||
| builder.add_task( | ||
| TaskNode( | ||
| task_id="Call Model", | ||
| ref_url="file://./noop.yaml", | ||
| arguments={"openai_api_key": dynamic_secret("OPENAI_API_KEY")}, | ||
| ) | ||
| ) | ||
| return emit_pipeline(builder)[0] | ||
|
|
||
|
|
||
| def test_dynamic_secret_emits_dynamic_data_argument(): | ||
| body = _body_with_dynamic_secret() | ||
|
|
||
| emitted = body["implementation"]["graph"]["tasks"]["Call Model"]["arguments"]["openai_api_key"] | ||
| assert emitted == {"dynamicData": {"secret": {"name": "OPENAI_API_KEY"}}} | ||
| validate_dehydrated_pipeline(body) | ||
|
|
||
|
|
||
| def test_dynamic_secret_fixture_compiles_and_validates(tmp_path): | ||
| """Keep a real Python pipeline example alongside the dynamic-data tests.""" | ||
| output = tmp_path / "dynamic_secret.yaml" | ||
| compile_pipeline(FIXTURES / "dynamic_secret_pipeline.py", output) | ||
| body = yaml.safe_load(output.read_text()) | ||
|
|
||
| argument = body["implementation"]["graph"]["tasks"]["Call Model"]["arguments"] | ||
| assert argument["openai_api_key"] == { | ||
| "dynamicData": {"secret": {"name": "OPENAI_API_KEY"}} | ||
| } | ||
| validate_dehydrated_pipeline(body) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "dynamic_data", | ||
| [ | ||
| {}, | ||
| {"secret": {}}, | ||
| {"secret": {"name": ""}}, | ||
| {"arbitrary": [1]}, | ||
| {"secret": {"name": "OPENAI_API_KEY", "extra": "value"}}, | ||
| ], | ||
| ) | ||
| def test_dynamic_data_rejects_invalid_secret_references(dynamic_data: dict): | ||
| body = _body_with_dynamic_secret() | ||
| argument = body["implementation"]["graph"]["tasks"]["Call Model"]["arguments"]["openai_api_key"] | ||
| argument["dynamicData"] = copy.deepcopy(dynamic_data) | ||
|
|
||
| with pytest.raises(SchemaValidationError): | ||
| validate_dehydrated_pipeline(body) | ||
|
|
||
|
|
||
| def test_dynamic_secret_requires_non_empty_name(): | ||
| with pytest.raises(ValueError, match="non-empty secret name"): | ||
| dynamic_secret("") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(AI-assisted) [nit] Only
dynamic_secretis re-exported here, notDynamicData. The PR description says "Both re-exported fromtangle_cli.python_pipeline(added to__all__)", butDynamicData— the type users would annotate orisinstance-check against — is reachable only via the.dynamic_datasubmodule. Either addDynamicDatato the import +__all__too, or adjust the description.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept
DynamicDatainternal (onlydynamic_secretis in__all__) and corrected the PR description to match — the "both re-exported" line was the inaccurate part.DynamicDatais only an emit primitive, so users never need to annotate orisinstance-check against it.