Skip to content

Render In[T] and graph_input defaults as the schema's string (v0.1.25) - #68

Merged
Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-fix-non-string-in-t-s-8551cd5
Sep 25, 2026
Merged

Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-fix-non-string-in-t-s-8551cd5

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

(AI-assisted)

What

InputSpec.default is string | null in the pipeline schema, but the tracer wrote a signature default straight through. So threshold: In[int] = 5 — the shape In's own docstring shows — emitted:

- name: threshold
  type: Integer
  default: 5        # an integer, where the schema wants a string

Nothing caught it. collect_pipeline_spec_errors returns [] for that document, so the only symptom was downstream. Authors had already worked around it by lying about the type: sample_size: In[str] = "0", timeout_secs: In[str] = "60", retry_count: In[str] = "4" all appear in our monorepo. After this change they can declare the type they mean.

Defaults now go through one serializer, on both the signature route and 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
{"b": 1, "a": 2}, [2, 1] '{"a": 2, "b": 1}', '[2, 1]' JSON, sorted keys
None null Schema-valid, so left alone
enum, dataclass, datetime, Path, … refused Named by type, never by value
nan, inf refused JSON has no spelling for them

Boolean spelling follows the two things that already exist rather than a preference: @task component inputs serialize True as "True", and the corpus writes Python spelling 49 times against 13. The generated container's _deserialize_bool lowercases before matching, so both parse — a test pins that pairing instead of assuming it.

A default's type must agree with the declared one: In[int] = 1.5, In[str] = 5 and In[int] = True are all refused. bool is a Python int subclass but not a Tangle Integer, so that last one needs an explicit guard. In[float] = 1 is allowed and renders '1', because whole-number Float defaults are idiomatic and the corpus already contains '0' and '72'.

How

New tangle_cli/input_defaults.py — stdlib-only, error_cls injected by the caller, the same shape as editor_layout from 0.1.22. A test spawns a subprocess to confirm importing it pulls in no tangle_cli.python_pipeline module, so the minimal-install packaging guards stay green. New InvalidInputDefaultError(CompileError) for the signature route.

The @task component path keeps its lenient str(value) fallback. Tightening it looked free — 55 str and 2 None defaults across every @task in our monorepo, nothing exotic — but that is a long-standing public surface whose users we cannot survey, a newly-failing compile there would have no migration path, and the reported bug is not there. The two paths never disagree about a value they both accept; a test asserts the Boolean spelling matches across them. If we later want the component path strict, it is a one-line delegation.

Failure modes

graph_input(default=...) changes behaviour relative to 0.1.23. A non-string default used to raise InvalidGraphIoError; it is now rendered. That is strictly more permissive, so no previously-valid call breaks, and the one 0.1.23 test asserting the rejection is inverted here on purpose — it is the only pre-existing test whose meaning this PR changes.

A pre-rendered string stays accepted whatever the declared type is, on that route only. Applied naively, the type rule would reject graph_input("n_samples", "Integer", default="-1") — which is how all 592 defaults in the corpus are written and exactly what a ported _gin call produces. A graph_input type is a Tangle type string describing the wire form, where every value is text; In[int] is a Python annotation, so In[int] = "41" is a mislabelled signature and is still refused. This is the one place the two surfaces deliberately differ, and it is documented.

Nothing in the wild changes. Every one of the 592 corpus defaults was replayed through the new serializer with its declared type: 0 changed, 0 rejected. No Python pipeline in the monorepo uses a non-string In[T] default, so no compiled output moves.

graph_input(default=None) still raises — omit the argument to declare no default. A signature = None still emits default: null, which is schema-valid; rewriting it would churn documents for no gain.

Review focus

  • The pre-rendered-string escape in _check_declared_type, and whether the signature/graph_input asymmetry is the right call.
  • Leaving the @task component path lenient.
  • Allowing int-for-float.

Tophatting

uv run --frozen pytest tests/test_signature_defaults.py

40 tests on real compile output, each rule exercised through both routes: acceptance per type, YAML-level quoting (default: '41' in the file, not just in memory), byte-identity for string defaults, every refusal, and diagnostics checked against a planted secret and a distinctive number.

Six mutations, each caught: dropping the type check, confusing bool with int, dropping the finite check, restoring the str(value) catch-all, dropping sort_keys, and dropping the pre-rendered-string escape. The catch-all mutation initially survived — the type check masked it — so there is now a Json-typed case where no type rule applies and only the supported-types gate stands between a Path and text nobody meant to ship.

Checklist

  • Full suite green locally on Python 3.13: 1943 passed.
  • Version bumped to 0.1.25 in pyproject.toml, packages/tangle-cli/src/tangle_cli/__init__.py, tests/test_packaging.py, and the uv.lock editable self-entry (lock diff is the one-line version change only).
  • README documents the table, the type rule, and the wire-form exception.

`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.
@Volv-G
Volv-G requested a review from Ark-kun as a code owner September 25, 2026 22:13
@Volv-G
Volv-G merged commit 124fa2d into master Sep 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant