Skip to content

Python 3.10 implicit-Optional fix; emit every componentRef locator (v0.1.26) - #69

Merged
Volv-G merged 2 commits into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-fix-implicit-optional-2c8b279
Sep 26, 2026
Merged

Volv-G merged 2 commits into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-fix-implicit-optional-2c8b279

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

(AI-assisted)

Two independent authoring fixes, both blocking Python-authored pipelines that the YAML corpus already contains.


1. Accept a None default on In[T] under Python 3.10

What

x: In[str] = None fails to compile on Python 3.10 with pipeline parameter 'x' must be annotated In[T] or Out[T]. It works on 3.11+.

This is pre-existing, not a 0.1.25 regression — 0.1.24 and 0.1.25 fail identically on 3.10. pyproject.toml declares requires-python = ">=3.10", but CI only tests 3.12/3.13, so nothing caught it.

Why

Python 3.10's get_type_hints still applies PEP 484 implicit Optional: a parameter with a None default has its annotation silently rewritten to Optional[T]. 3.11 removed that behaviour.

py3.10  {'a': typing.Optional[tangle_cli.python_pipeline.types.In[str]]}
py3.11  {'a': tangle_cli.python_pipeline.types.In[str]}

In[T] detection is getattr(annotation, "__origin__", None) is In, and on the rewritten annotation that origin is typing.Union, so the parameter stops looking like a graph input.

How

_strip_optional_none(annotation, default) in python_pipeline/trace.py, next to the existing _is_in_annotation: when the default is None and the annotation is a two-arm Optional, unwrap it. Applied in the @pipeline signature loop and in the cfg predicate in pipeline_compiler.py — the only two sites that read resolved hints for pipeline parameters.

Written as a behavioural rule, not a sys.version_info check, so all four supported Pythons take the same code path rather than 3.10 running code the CI Pythons never execute.

Blast radius checked: component_from_func.py (the @task path) is unaffected — it produces identical component YAML on 3.10 and 3.12 — and return annotations don't go through this path.


2. Emit every componentRef locator ref() is given

What

ref() rejected url= together with name= as "conflicting locators". Five corpus pipelines could therefore not be expressed in Python at all, across 29 task refs:

pipeline shape
relevance-tools/…/oasis/pipelines/lgbm_from_dnn.yaml {name, url} ×2
…/search_reranker/tangle/pipeline/build_vantage_features… {name, url}
…/relevance_judge… {name, url} ×15
…/daily_vantage_s… {digest, name, url} ×4
…/daily_pulse_scr… {digest, name, url} ×3

Why the rejection was wrong

The dehydrated schema's DehydratedComponentRef is anyOf: [url, digest, name] with no mutual exclusion — {url, name} and {url, name, digest} both validate today.

And the hydrator supports it deliberately. _resolve_task collects every present locator key; with two or more it calls _resolve_best_ref, which resolves each one independently and hands the candidates to _pick_best_candidate, keeping the highest component version (utils.get_version_from_data), tie-breaking digest > name > url.

So the semantics are worth stating plainly, because they are not the obvious reading:

  • a second locator is not a narrowing filter, and name is neither a selector within the URL nor a validation of the fetched component;
  • there is no mismatch error — two locators pointing at unrelated components silently resolve to whichever has the higher version, and the name can win over the url;
  • a locator that fails to resolve is warned about and skipped; if all fail it falls back to the first;
  • the winner replaces the whole ref with its {name, digest, spec}.

ref(url=…, name=…) means "use this file, but prefer the published component if it is newer".

How

ref() now accepts any combination of url / name / digest; the XOR check is gone. _emit_component_ref changed from first-branch-wins to accumulating every locator present, in the canonical key order url, name, digest — extending the existing "url first, digest last" rule. Corpus files happen to write name-first, but the emitter has always imposed its own order, and nothing keys on it (sidecar/dedup identity is unaffected).

tag= stays rejected (the hydrator has no tag fetcher and tag is not even a schema property), as does a ref with no locator. Diagnostics name the keyword, never the value.

No compile-time validation of the combination is added: detecting a "mismatch" would require resolving both locators at compile time, which the compiler never does.


Failure modes

  • (1) An explicit Optional[In[str]] = None now compiles on 3.11+ where it previously raised. That turns an error into output; it never changes output that already existed. Byte-identity is pinned by a test.
  • (1) A wider union (Union[A, B, None]) is deliberately left alone: unwrapping would invent a type the author never wrote.
  • (2) Strictly more permissive — every previously legal ref() call emits exactly what it emitted before. Only the previously-rejected combinations are new.

Review focus

  • The unwrap condition in _strip_optional_none — is "two-arm Optional plus a None default" the right trigger?
  • The canonical locator key order url, name, digest, given the corpus writes name-first.
  • Whether emitting a name beside a url deserves a compile-time warning, since the resolved component may not be the one at the url.

Tophatting

uv run --frozen --python 3.10 pytest -q   # 1966 passed
uv run --frozen --python 3.12 pytest -q   # 1966 passed

On master, 3.10 shows exactly 1 failed (test_signature_defaults.py::test_a_none_default_is_left_alone).

Checklist

  • 7 new tests in tests/test_optional_annotations.py; 16 in tests/test_ref_locator_combinations.py (every legal combination's emitted shape, canonical key order independent of kwarg order, the corpus shape, chaining through .named/.bind/.with_annotations, two hydration tests proving the version pick in both directions, a compile→hydrate end-to-end, and the surviving rejections).
  • Mutation-tested: 3 mutations for (1), 6 for (2) — name dropped when url present, key order swapped, XOR restored, no-locator guard dropped, tag guard dropped, digest dropped alongside url+name. All caught.
  • Compiled output under (1) on 3.10, 3.11 and 3.12 all hash b737f4bfa79c8664, identical to released 0.1.25 on 3.12.
  • Full frozen suite 1966 passed on 3.10 and 3.12; ruff clean on touched files; packaging guards 10 passed; git diff --check clean.
  • uv.lock hand-edited on the tangle-cli self-entry only (one line); uv lock was not run.
  • pyright not run: it is not a repo dependency and the internal npm proxy has no package for it.
  • README documents both: the locator table with the "highest version wins" semantics, and the existing input-defaults section is unchanged.

Follow-up (not in this PR)

requires-python = ">=3.10" with a ["3.12", "3.13"] CI matrix is what let (1) ship. The suite passes clean on 3.10 and 3.11 today, so adding them to the matrix is a one-line change whenever you want it.

Python 3.10's get_type_hints still applies PEP 484 implicit Optional,
which 3.11 removed, so `x: In[str] = None` resolved to
Optional[In[str]] and was no longer recognised as a graph input.

Strip an Optional wrapper when it is paired with a None default, in
the @pipeline signature path and the cfg predicate. Written as a
behavioural rule rather than a version check, so all supported
Pythons take the same code path. A wider union is left alone.
@Volv-G
Volv-G requested a review from Ark-kun as a code owner September 26, 2026 01:56
ref() rejected url= with name=, so five corpus pipelines under
relevance/experiments and relevance-tools could not be expressed in
Python at all. The dehydrated schema allows url, name and digest
together, and the hydrator resolves each present locator independently
and keeps the highest component version.

Allow any combination of the three and emit each one in the canonical
key order url, name, digest. Multiple locators are an upgrade path,
not a narrowing filter: the name can win over the url. tag= and a
ref with no locator stay rejected.
@Volv-G Volv-G changed the title Accept a None default on In[T] under Python 3.10 (v0.1.26) Python 3.10 implicit-Optional fix; emit every componentRef locator (v0.1.26) Sep 26, 2026
@Volv-G
Volv-G merged commit b20ad93 into master Sep 26, 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