From df4702acd78a66633eee4f8c0b7a82c3ecf3246e Mon Sep 17 00:00:00 2001 From: Volv G Date: Fri, 25 Sep 2026 15:13:11 -0700 Subject: [PATCH] Render In[T] and graph_input defaults as the schema's string (v0.1.25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `InputSpec.default` is `string | null`, but the tracer wrote a signature default straight through, so `threshold: In[int] = 5` — the shape `In`'s own docstring shows — emitted `default: 5`. Nothing rejected it: the local validator passes it, and authors worked around it by declaring numbers as `In[str] = "60"`. Defaults now go through one serializer: strings unchanged, numbers and booleans rendered (`True`, matching @task component inputs and the corpus), lists and dicts as JSON with sorted keys, `None` still written as `default: null`. A default whose type contradicts the declared `T` is refused, as is a type with no sensible rendering and a non-finite float; diagnostics name the input and the type, never the value. `graph_input(default=...)` accepts the same values instead of demanding a pre-rendered string, and keeps passing one through untouched so ported YAML emits the bytes it always did. All 592 defaults in the pipeline corpus round-trip unchanged. The @task component path keeps its lenient `str(value)` fallback: it is a long-standing public surface whose users we cannot survey, and the bug is not there. --- README.md | 20 +- .../tangle-cli/src/tangle_cli/__init__.py | 2 +- .../src/tangle_cli/input_defaults.py | 109 +++++++ .../src/tangle_cli/python_pipeline/errors.py | 8 + .../tangle_cli/python_pipeline/graph_io.py | 19 +- .../src/tangle_cli/python_pipeline/trace.py | 24 +- pyproject.toml | 2 +- tests/test_graph_io.py | 29 +- tests/test_packaging.py | 2 +- tests/test_signature_defaults.py | 290 ++++++++++++++++++ uv.lock | 2 +- 11 files changed, 491 insertions(+), 16 deletions(-) create mode 100644 packages/tangle-cli/src/tangle_cli/input_defaults.py create mode 100644 tests/test_signature_defaults.py diff --git a/README.md b/README.md index 635b5b1..4f0c0a9 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,23 @@ def greeting_pipeline(who: In[str], cfg) -> Out[str]: `In[T]` parameters become runtime graph inputs. A single `-> Out[T]` return exposes one graph output; use `@pipeline(output_name=...)` to name that output. For multiple outputs, define a frozen dataclass subclass of `Outputs` with `Out[T]` fields and return an instance. A pipeline that accepts a `cfg` parameter reads `config.yaml` (or the path passed via `@pipeline(config="...")`) at compile time, with `--override key=value` values overlaid by the compile command. +###### Input defaults + +A parameter default becomes the input's `default`, which the schema carries as a string, so `threshold: In[int] = 5` emits `default: '5'`. The same rules apply to `graph_input(default=...)`: + +| Default | Emitted | Note | +| --- | --- | --- | +| `"x"` | `x` | Unchanged, always accepted | +| `5`, `-1` | `'5'`, `'-1'` | | +| `1.5` | `'1.5'` | `In[float] = 1` emits `'1'`, not `'1.0'` | +| `True` / `False` | `'True'` / `'False'` | Python spelling, as `@task` component inputs use; the generated container parses it case-insensitively | +| `{"b": 1, "a": 2}`, `[2, 1]` | `'{"a": 2, "b": 1}'`, `'[2, 1]'` | JSON with sorted keys, so the document is reproducible | +| `None` | `null` | Schema-valid; `graph_input` instead rejects an explicit `default=None` — omit the argument | + +Anything else — an enum, a dataclass, a `datetime`, a `Path` — raises `InvalidInputDefaultError` (a `CompileError`) naming the input and the type, never the value. Render it yourself and pass the string. A non-finite float (`nan`, `inf`) is rejected for the same reason: JSON has no spelling for it. + +The default's type must also agree with the declared one. `In[int] = 1.5`, `In[str] = 5`, and `In[int] = True` (a `bool` is a Python `int`, but not a Tangle `Integer`) are all refused. A `graph_input` type is a Tangle type string describing the wire form, so it additionally accepts an already-rendered string: `graph_input("n_samples", "Integer", default="-1")` emits exactly `-1`, which is how every default in the corpus is written. Types with no scalar Python equivalent, such as `Json`, are not type-checked. + ##### Environment-selected pipeline config (`_select`, `_env`) A pipeline `config.yaml` — root or child, including one broadcast with `propagate_config=True` — is resolved by the same loader as `--config` files (see [`_select`](#environment-selected-configs-_select) and [`_env`](#environment-variable-values-_env)), with one shape rule: the selected document, and every case and `default` branch, must be a mapping. `_defaults`/`configs` have no special meaning in a pipeline config; they are ordinary keys. @@ -664,7 +681,8 @@ def daily_pulse() -> Out[str]: - **A name is declared once.** Duplicates are rejected, including a collision with an `In[...]` parameter or with the name the return annotation contributes (`Out[T]`'s `output_name`, or an `Outputs` field). - **Outputs wire to a handle, never a constant** — a task output or a graph input, the same rule the `Outputs` return path enforces. Returning a value *and* declaring outputs is supported: the returned output is appended after the declared ones. - **Layout comes along**: `position=(x, y)` writes the same canonical `editor.position` annotation as `.with_position(...)`, which is how a graph input gets a position in the editor. `annotations={...}` is validated under the same caller policy as `pipeline_annotations`. -- **Validated up front, without echoing values.** A non-string `default` (the schema's `InputSpec.default` is a string), a non-string name/type/description, a non-boolean `optional`, a constant output, or a call outside a `@pipeline` body raises `InvalidGraphIoError` (a `CompileError`) naming the field, never the value. +- **`default` follows the [input-default rules](#input-defaults)** — scalars and JSON containers are rendered for you, an already-rendered string is passed through untouched, and the type must agree with the declared Tangle type. +- **Validated up front, without echoing values.** A non-string name/type/description, a non-boolean `optional`, a constant output, or a call outside a `@pipeline` body raises `InvalidGraphIoError` (a `CompileError`) naming the field, never the value. The signature route remains the recommended default: it is typed, it is checked by your IDE, and it reads better. Reach for these when porting existing YAML or when the shape genuinely needs it. diff --git a/packages/tangle-cli/src/tangle_cli/__init__.py b/packages/tangle-cli/src/tangle_cli/__init__.py index c4fa030..6aa85f8 100644 --- a/packages/tangle-cli/src/tangle_cli/__init__.py +++ b/packages/tangle-cli/src/tangle_cli/__init__.py @@ -14,6 +14,6 @@ try: __version__ = metadata_version("tangle-cli") except PackageNotFoundError: - __version__ = "0.1.24" + __version__ = "0.1.25" __all__ = ["TangleDynamicDiscoveryClient", "__version__"] diff --git a/packages/tangle-cli/src/tangle_cli/input_defaults.py b/packages/tangle-cli/src/tangle_cli/input_defaults.py new file mode 100644 index 0000000..598a133 --- /dev/null +++ b/packages/tangle-cli/src/tangle_cli/input_defaults.py @@ -0,0 +1,109 @@ +"""Serialize a Python graph-input default into the string the schema wants. + +``InputSpec.default`` is ``string | null``, so every default a pipeline +declares from Python has to be rendered as text. Callers inject ``error_cls`` +and a field label; diagnostics name the field and the type, never the value. +""" +from __future__ import annotations + +import json +import math +from typing import Any + +# Types a default may be written as. Anything else is refused rather than +# guessed at: ``str(value)`` on an enum or a dataclass produces text no one +# meant to put in a pipeline document. +SUPPORTED_DEFAULT_TYPES = (str, bool, int, float, list, dict) + +# Tangle type strings that pin a default to a Python type. Json and JsonArray +# are deliberately absent: their values are structural, not scalar. +_TANGLE_SCALARS: dict[str, type] = { + "String": str, + "Integer": int, + "Float": float, + "Boolean": bool, +} + + +def serialize_input_default( + value: Any, + *, + field: str, + declared_type: Any = None, + error_cls: type[Exception] = ValueError, +) -> str | None: + """Return the YAML form of ``value``, or ``None`` to write no default. + + ``declared_type`` is a Python type or a Tangle type string; when it names + a builtin scalar the default's type must match it. A Tangle type string + additionally accepts an already-rendered ``str``, which is the wire form + and how every default in the corpus is written. Booleans render in Python + spelling (``"True"``), matching ``@task`` component inputs and the corpus; + containers render as JSON with sorted keys so the document is reproducible. + """ + if value is None: + return None + if not isinstance(value, SUPPORTED_DEFAULT_TYPES): + supported = ", ".join(t.__name__ for t in SUPPORTED_DEFAULT_TYPES) + raise error_cls( + f"{field} default must be one of {supported} or None; " + f"got {type(value).__name__}. Render it yourself and pass a string." + ) + _check_declared_type(value, declared_type, field=field, error_cls=error_cls) + if isinstance(value, str): + return value + if isinstance(value, bool): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise error_cls( + f"{field} default must be a finite number; " + f"got a non-finite float. JSON has no spelling for it." + ) + return str(value) + if isinstance(value, int): + return str(value) + try: + return json.dumps(value, sort_keys=True) + except (TypeError, ValueError) as exc: + raise error_cls( + f"{field} default must be JSON-serializable; " + f"{type(value).__name__} contains a value that is not." + ) from exc + + +def _check_declared_type( + value: Any, + declared_type: Any, + *, + field: str, + error_cls: type[Exception], +) -> None: + expected = declared_type + if isinstance(declared_type, str): + expected = _TANGLE_SCALARS.get(declared_type) + # A Tangle type string describes the wire form, where every value is + # text. A pre-rendered default is therefore always in range, so + # ported YAML keeps emitting exactly the bytes it did before. + if isinstance(value, str): + return + if expected not in (str, bool, int, float): + return + + actual = type(value) + # bool is a subclass of int, so an unguarded isinstance would let + # ``In[int] = True`` through and emit "True" for an Integer input. + if expected is bool: + ok = actual is bool + elif expected is float: + # A whole number written for a Float input is idiomatic and appears + # in the corpus ("0", "72"); it renders as-is, not as "0.0". + ok = actual is float or actual is int + else: + ok = actual is expected + + if not ok: + raise error_cls( + f"{field} is declared {expected.__name__} but its default is " + f"{actual.__name__}. Declare the matching type or convert the default." + ) diff --git a/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py b/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py index 924f4e2..86e12e9 100644 --- a/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +++ b/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py @@ -38,6 +38,14 @@ class InvalidEditorLayoutError(CompileError): """ +class InvalidInputDefaultError(CompileError): + """Raised on an ``In[T]`` parameter default the schema cannot carry. + + Covers an unsupported type, a non-finite float, and a default whose type + contradicts the declared ``T``. Messages name the input, never the value. + """ + + class InvalidGraphIoError(CompileError): """Raised on an unusable ``graph_input()`` / ``graph_output()`` call. diff --git a/packages/tangle-cli/src/tangle_cli/python_pipeline/graph_io.py b/packages/tangle-cli/src/tangle_cli/python_pipeline/graph_io.py index cc5d504..43e9271 100644 --- a/packages/tangle-cli/src/tangle_cli/python_pipeline/graph_io.py +++ b/packages/tangle-cli/src/tangle_cli/python_pipeline/graph_io.py @@ -17,6 +17,7 @@ from typing import Any from tangle_cli.editor_layout import POSITION_ANNOTATION, position_annotation_value +from tangle_cli.input_defaults import serialize_input_default from tangle_cli.schema_validation import CALLER_ANNOTATION_POLICY, check_annotations from .errors import InvalidEditorLayoutError, InvalidGraphIoError @@ -74,9 +75,21 @@ def graph_input( if description is not None: entry["description"] = _check_str("description", description) if default is not _UNSET: - # InputSpec.default is a string in the schema; a non-string default - # would only fail later, against a schema path instead of this call. - entry["default"] = _check_str("default", default, hint="pass str(value)") + # InputSpec.default is a string in the schema, so the value is + # rendered here rather than failing later against a schema path. + # An explicit default=None is a mistake worth reporting: omit the + # argument to declare no default. + if default is None: + raise InvalidGraphIoError( + "default must not be None. Omit the argument to declare " + "an input with no default." + ) + entry["default"] = serialize_input_default( + default, + field=f"graph input {name!r}", + declared_type=type, + error_cls=InvalidGraphIoError, + ) entry["optional"] = True if optional is None else _check_bool(optional) elif optional is not None: entry["optional"] = _check_bool(optional) diff --git a/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py b/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py index 6f955dd..e8d8570 100644 --- a/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +++ b/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py @@ -32,7 +32,9 @@ from contextvars import ContextVar from typing import TYPE_CHECKING, Any -from .errors import CompileError +from tangle_cli.input_defaults import serialize_input_default + +from .errors import CompileError, InvalidInputDefaultError from .graph import EdgeRef, GraphBuilder from .placeholders import GraphInputPlaceholder, TaskOutputProxy from .types import In, Out, Outputs @@ -166,6 +168,20 @@ def declared_output_names(pipeline_fn: "PipelineFn") -> tuple[str, ...] | None: return None +def _serialize_signature_default(value: Any, name: str, inner: Any) -> Any: + """Return the YAML form of an ``In[T]`` parameter default. + + ``None`` is passed through: ``default: null`` is schema-valid, and + rewriting it would change existing documents for no gain. + """ + return serialize_input_default( + value, + field=f"input {name!r}", + declared_type=inner, + error_cls=InvalidInputDefaultError, + ) + + def _python_type_to_tangle_type(t: Any) -> str: """Map ``str``/``int``/etc. to Tangle type strings.""" if t is str: @@ -243,7 +259,11 @@ def trace_pipeline( has_default = param.default is not inspect.Parameter.empty if has_default: - entry["default"] = param.default + # ``InputSpec.default`` is a string in the schema, so a bare + # ``In[int] = 5`` used to emit a schema-invalid ``default: 5``. + entry["default"] = _serialize_signature_default( + param.default, param_name, inner + ) entry["optional"] = True builder.inputs.append(entry) diff --git a/pyproject.toml b/pyproject.toml index bc4bcb1..77f768d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.1.24" +version = "0.1.25" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ diff --git a/tests/test_graph_io.py b/tests/test_graph_io.py index 366bce1..7e7bf2c 100644 --- a/tests/test_graph_io.py +++ b/tests/test_graph_io.py @@ -224,10 +224,11 @@ def p() -> Out[str]: ] -def test_a_non_string_default_is_refused_without_echoing_it(tmp_path): - """``InputSpec.default`` is a string in the pipeline schema; catching it - here names the field instead of failing later against a schema path.""" - message = _expect( +def test_a_scalar_default_is_rendered_as_the_schema_string(tmp_path): + """``InputSpec.default`` is a string in the pipeline schema, so the value + is rendered here rather than failing later against a schema path. Full + rules and refusals live in ``test_signature_defaults.py``.""" + doc = _doc(_compile( tmp_path, ''' @pipeline("P") @@ -236,11 +237,27 @@ def p() -> Out[str]: run_greet = greet(greeting=value) return run_greet ''', + "scalar_default", + )) + + assert doc["inputs"][0]["default"] == "41" + + +def test_an_unrenderable_default_is_refused_without_echoing_it(tmp_path): + message = _expect( + tmp_path, + ''' + @pipeline("P") + def p() -> Out[str]: + value = graph_input("value", "Integer", default=4.1) + run_greet = greet(greeting=value) + return run_greet + ''', "bad_default", ) - assert "default" in message and "int" in message - assert "41" not in message + assert "default" in message and "float" in message + assert "4.1" not in message # --------------------------------------------------------------------------- diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 184bbd6..e208db5 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -183,7 +183,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.1.24" in metadata + assert "Version: 0.1.25" in metadata assert "Requires-Dist: tangle-api==0.1.1" in requires_dist assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata diff --git a/tests/test_signature_defaults.py b/tests/test_signature_defaults.py new file mode 100644 index 0000000..60fa38a --- /dev/null +++ b/tests/test_signature_defaults.py @@ -0,0 +1,290 @@ +"""Graph-input defaults are rendered as the string the schema wants, or refused. + +``InputSpec.default`` is ``string | null``, and all 592 defaults across our +pipeline corpus are strings. A bare ``In[int] = 5`` — the shape ``In``'s own +docstring shows — emitted ``default: 5``, a schema-invalid document nothing +rejected locally. Authors worked around it by declaring numbers as +``In[str] = "60"``. + +Rendering is deliberately narrow: six types in, everything else refused by +name, because ``str()`` on an enum or a dataclass produces text no one meant +to ship. ``graph_input(default=...)`` accepts the same values through the same +serializer. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +import yaml + +from tangle_cli.component_from_func import _serialize_default +from tangle_cli.input_defaults import serialize_input_default +from tangle_cli.pipeline_compiler import compile_pipeline +from tangle_cli.python_pipeline.errors import CompileError, InvalidInputDefaultError + +_PREAMBLE = ''' +from tangle_cli.python_pipeline import In, Out, graph_input, pipeline, task + + +@task(image="python:3.12") +def greet(greeting: str = "hi"): + """Write a greeting. + + Metadata: + Name: Greet + """ + print(greeting) +''' + +_SIGNATURE = _PREAMBLE + ''' + +@pipeline("Defaults") +def defaults(value: In[__TYPE__] = __DEFAULT__) -> Out[str]: + run_greet = greet(greeting=value) + return run_greet +''' + +_BODY = _PREAMBLE + ''' + +@pipeline("Defaults") +def defaults() -> Out[str]: + value = graph_input("value", __TYPE__, default=__DEFAULT__) + run_greet = greet(greeting=value) + return run_greet +''' + + +def _write(tmp_path: Path, template: str, type_repr: str, default_repr: str, case: str) -> Path: + case_dir = tmp_path / case + case_dir.mkdir(parents=True, exist_ok=True) + script = case_dir / "pipeline.py" + script.write_text( + textwrap.dedent(template) + .replace("__TYPE__", type_repr) + .replace("__DEFAULT__", default_repr), + encoding="utf-8", + ) + return script + + +def _compile(tmp_path: Path, template: str, type_repr: str, default_repr: str, case: str) -> dict: + script = _write(tmp_path, template, type_repr, default_repr, case) + out = script.parent / "compiled.yaml" + compile_pipeline(script, out) + return yaml.safe_load(out.read_text(encoding="utf-8")) + + +def _compile_error( + tmp_path: Path, template: str, type_repr: str, default_repr: str, case: str +) -> str: + script = _write(tmp_path, template, type_repr, default_repr, case) + with pytest.raises(CompileError) as excinfo: + compile_pipeline(script, script.parent / "compiled.yaml") + return str(excinfo.value) + + +# ============================================================================ +# Accepted values, through the signature and the body +# ============================================================================ + + +@pytest.mark.parametrize( + "py_type, tangle_type, default_repr, expected", + [ + ("str", '"String"', '"x"', "x"), + ("str", '"String"', '"41"', "41"), + ("int", '"Integer"', "41", "41"), + ("int", '"Integer"', "0", "0"), + ("int", '"Integer"', "-1", "-1"), + ("float", '"Float"', "1.5", "1.5"), + ("bool", '"Boolean"', "True", "True"), + ("bool", '"Boolean"', "False", "False"), + ], +) +@pytest.mark.parametrize("route", ["signature", "body"]) +def test_a_scalar_default_is_emitted_as_a_string( + tmp_path, py_type, tangle_type, default_repr, expected, route +): + template, type_repr = ( + (_SIGNATURE, py_type) if route == "signature" else (_BODY, tangle_type) + ) + doc = _compile( + tmp_path, template, type_repr, default_repr, f"{route}_{py_type}_{expected}" + ) + + entry = doc["inputs"][0] + assert entry["default"] == expected + assert isinstance(entry["default"], str) + assert entry["optional"] is True + + +def test_the_yaml_text_quotes_a_numeric_default(tmp_path): + """Round-tripping must not turn the string back into a number, which is + what makes this a real fix rather than an in-memory detail.""" + script = _write(tmp_path, _SIGNATURE, "int", "41", "quoting") + out = script.parent / "compiled.yaml" + compile_pipeline(script, out) + + assert "default: '41'" in out.read_text(encoding="utf-8") + + +def test_a_string_default_is_unchanged(tmp_path): + """The overwhelmingly common case stays byte-identical to before.""" + doc = _compile(tmp_path, _SIGNATURE, "str", '"already a string"', "unchanged") + + assert doc["inputs"][0]["default"] == "already a string" + + +def test_a_none_default_is_left_alone(tmp_path): + """``default: null`` is schema-valid; rewriting it would churn existing + documents for no gain.""" + doc = _compile(tmp_path, _SIGNATURE, "str", "None", "none_default") + + entry = doc["inputs"][0] + assert entry["default"] is None + assert entry["optional"] is True + + +def test_a_whole_number_for_a_float_input_renders_as_written(tmp_path): + """Integer-looking Float defaults are idiomatic and appear in the corpus + ('0', '72'), so they render as-is rather than as '1.0'.""" + doc = _compile(tmp_path, _SIGNATURE, "float", "1", "int_for_float") + + assert doc["inputs"][0]["default"] == "1" + + +def test_container_defaults_serialize_as_sorted_json(): + """Sorted keys keep the document reproducible across runs.""" + field = "input 'x'" + assert serialize_input_default({"b": 1, "a": 2}, field=field) == '{"a": 2, "b": 1}' + assert serialize_input_default(["b", "a"], field=field) == '["b", "a"]' + + +# ============================================================================ +# Refused values +# ============================================================================ + + +@pytest.mark.parametrize( + "py_type, tangle_type, default_repr, case", + [ + ("int", '"Integer"', "1.5", "float_for_int"), + ("str", '"String"', "5", "int_for_str"), + ("int", '"Integer"', "True", "bool_for_int"), + ("bool", '"Boolean"', "1", "int_for_bool"), + ], +) +@pytest.mark.parametrize("route", ["signature", "body"]) +def test_a_default_contradicting_the_declared_type_is_refused( + tmp_path, py_type, tangle_type, default_repr, case, route +): + template, type_repr = ( + (_SIGNATURE, py_type) if route == "signature" else (_BODY, tangle_type) + ) + message = _compile_error(tmp_path, template, type_repr, default_repr, f"{route}_{case}") + + assert "is declared" in message + assert "its default is" in message + + +def test_a_prerendered_string_default_is_accepted_for_any_tangle_type(tmp_path): + """Every default in the corpus is written as text, so a ported + ``graph_input("n", "Integer", default="-1")`` must keep emitting the bytes + it did before this rule existed.""" + doc = _compile(tmp_path, _BODY, '"Integer"', '"-1"', "prerendered") + + assert doc["inputs"][0]["default"] == "-1" + + +def test_a_string_default_still_contradicts_a_python_annotation(tmp_path): + """``In[int]`` is a Python type, not a wire type: a string default is a + mislabelled signature, and naming it points at the right fix.""" + message = _compile_error(tmp_path, _SIGNATURE, "int", '"41"', "str_for_int_sig") + + assert "is declared int" in message + + +def test_an_unsupported_type_is_refused_by_name(tmp_path): + """An enum's ``str()`` is 'Colour.RED' — text no one meant to ship.""" + script = _write(tmp_path, _SIGNATURE, "str", "None", "unsupported") + script.write_text( + script.read_text(encoding="utf-8").replace( + "def defaults(value: In[str] = None)", + "def defaults(value: In[str] = __import__('pathlib').Path('/tmp/x'))", + ), + encoding="utf-8", + ) + with pytest.raises(InvalidInputDefaultError) as excinfo: + compile_pipeline(script, script.parent / "compiled.yaml") + + message = str(excinfo.value) + assert "PosixPath" in message + assert "/tmp/x" not in message + + +def test_an_unsupported_type_is_refused_where_no_type_rule_applies(tmp_path): + """The Json route is not type-checked, so only the supported-types gate + stands between a Path and ``str(value)`` text in the document.""" + message = _compile_error( + tmp_path, _BODY, '"Json"', "__import__('pathlib').Path('/tmp/x')", "json_path" + ) + + assert "must be one of" in message + assert "PosixPath" in message + assert "/tmp/x" not in message + + +@pytest.mark.parametrize("default_repr", ["float('nan')", "float('inf')"]) +def test_a_non_finite_float_is_refused(tmp_path, default_repr): + """JSON has no spelling for them, so nothing downstream could read one.""" + message = _compile_error( + tmp_path, _SIGNATURE, "float", default_repr, f"nonfinite_{len(default_repr)}" + ) + + assert "finite" in message + + +def test_the_body_route_refuses_an_explicit_none(tmp_path): + """Omitting the argument is the way to declare no default; passing None + is a mistake worth naming.""" + message = _compile_error(tmp_path, _BODY, '"String"', "None", "body_none") + + assert "Omit the argument" in message + + +def test_a_json_typed_input_is_not_type_checked(tmp_path): + """Json values are structural, so the scalar rule does not apply.""" + doc = _compile(tmp_path, _BODY, '"Json"', '{"b": 1, "a": 2}', "json_type") + + assert doc["inputs"][0]["default"] == '{"a": 2, "b": 1}' + + +# ============================================================================ +# Diagnostics and cross-path agreement +# ============================================================================ + + +def test_diagnostics_name_the_input_without_echoing_the_value(tmp_path): + message = _compile_error(tmp_path, _SIGNATURE, "int", "1.5000001", "no_echo") + + assert "'value'" in message + assert "1.5000001" not in message + + +def test_the_pipeline_and_component_paths_spell_a_boolean_the_same_way(tmp_path): + """One document must not spell the same value two ways: a Boolean default + reads ``True`` whether it is a graph input or a component input.""" + doc = _compile(tmp_path, _SIGNATURE, "bool", "True", "agreement") + + assert doc["inputs"][0]["default"] == _serialize_default(True, "Boolean") + + +def test_a_serialized_boolean_is_parsable_by_the_generated_container(): + """The container's deserializer lowercases first, so Python spelling is + safe; this pins the pairing rather than assuming it.""" + field = "input 'x'" + assert serialize_input_default(True, field=field).lower() in ("true", "1", "yes") + assert serialize_input_default(False, field=field).lower() in ("false", "0", "no") diff --git a/uv.lock b/uv.lock index f9d1a87..c93d043 100644 --- a/uv.lock +++ b/uv.lock @@ -2083,7 +2083,7 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.1.24" +version = "0.1.25" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" },