Skip to content

Escape the index note when structuring heterogeneous tuples - #777

Merged
Tinche merged 1 commit into
python-attrs:mainfrom
MaxFreedomPollard:escape-hetero-tuple-note
Sep 14, 2026
Merged

Tinche merged 1 commit into
python-attrs:mainfrom
MaxFreedomPollard:escape-hetero-tuple-note

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

On the default Converter, structuring a heterogeneous tuple whose type arguments have a quote in their repr raises SyntaxError before any data is looked at. Converter().structure(["a", 1], tuple[Literal["a"], int]) fails, while BaseConverter() and Converter(detailed_validation=False) handle the same call fine, so whether it works depends only on which converter you reach for. NamedTuples go through the same factory, so a NamedTuple with a Literal field fails too, which is the more likely way to hit this.

The cause is in make_hetero_tuple_structure_fn, src/cattrs/gen/__init__.py line 903 on main, which interpolates str(cl) into a single-quoted string literal in the generated source: f"__c_ivn('Structuring {cl} @ index {ix}', {ix}, {type_name})]". str(tuple[Literal["a"], int]) is tuple[typing.Literal['a'], int], so the emitted line is

      e.__notes__ = [*getattr(e, '__notes__', []), __c_ivn('Structuring tuple[typing.Literal['a'], int] @ index 0', 0, __c_type_0)]

and compiling the hook fails on it.

This is a regression in 26.2.0. Before #737 heterogeneous tuples went through _structure_tuple, which builds the note at runtime; BaseConverter still registers that path, which is why it is unaffected. It is also the same class of bug as #769 and #771, and src/cattrs/gen/typeddicts.py line 394 already uses exactly the fix applied here.

Reproducer:

from typing import Literal, NamedTuple

from cattrs import BaseConverter, Converter


class NT(NamedTuple):
    a: Literal["a"]
    b: int


for name, conv in [
    ("Converter()", Converter()),
    ("Converter(detailed_validation=False)", Converter(detailed_validation=False)),
    ("BaseConverter()", BaseConverter()),
]:
    for label, typ in [("tuple", tuple[Literal["a"], int]), ("NamedTuple", NT)]:
        try:
            print(f"{name} {label}: {conv.structure(['a', 1], typ)}")
        except Exception as e:
            print(f"{name} {label}: {type(e).__name__}: {e}")

On main, Python 3.11.15:

Converter() tuple: SyntaxError: invalid syntax. Perhaps you forgot a comma? (<cattrs generated structure builtins.tuple>, line 8)
Converter() NamedTuple: SyntaxError: invalid syntax. Perhaps you forgot a comma? (<cattrs generated structure builtins.tuple>, line 8)
Converter(detailed_validation=False) tuple: ('a', 1)
Converter(detailed_validation=False) NamedTuple: NT(a='a', b=1)
BaseConverter() tuple: ('a', 1)
BaseConverter() NamedTuple: NT(a='a', b=1)

The wording of the SyntaxError and the generated module name differ on other versions. On this branch all six lines print the structured value.

The fix builds the note text in Python and embeds it with repr, one new line and one changed line in make_hetero_tuple_structure_fn. The note text is byte-identical to what was emitted before, so nothing that reads IterableValidationNote changes, and no new branch is introduced. The test covers a quote from Literal, the same quote nested inside a List, an Annotated whose metadata repr contains both quote characters, and a NamedTuple field, then triggers a genuine validation failure and checks the note reads exactly as it does for a tuple whose arguments have no quotes: tuple[int, int] produces the same note on main and on the branch. It reuses the name of test_type_names_with_quotes in tests/test_gen_dict.py, which covers the same class of bug in the dict path. It builds its own Converter() instead of using the file's genconverter fixture because the bug is specific to the detailed validation generated path, and it builds the expected note from the same type expression so the assertion does not depend on how a given Python version renders typing objects.

Verification, all on the branch unless stated otherwise:

tests/test_tuples.py with only the src change reverted gives 1 failed, 16 passed, the failure being the SyntaxError above raised while compiling the generated hook. With the change in place the file is 17 passed.

The full suite on 3.11.15 is 867 passed, 15 xfailed. tests/preconf and tests/test_preconf.py were excluded because the optional serialization libraries are not installed in the environment I ran in; the change touches no preconf code.

ruff check src/ tests bench gives "All checks passed!" and ruff format --check src tests docs/conf.py gives "115 files already formatted", with ruff 0.15.6, the version pinned in uv.lock.

`make_hetero_tuple_structure_fn` builds the per-index note by interpolating
`str(cl)` into a single-quoted string literal in the generated source, at
`src/cattrs/gen/__init__.py` line 903 on main:
`f"__c_ivn('Structuring {cl} @ index {ix}', {ix}, {type_name})]"`. When a type
argument has a quote in its `repr`, such as `Literal["a"]`, the quote closes
the literal early and compiling the hook raises `SyntaxError` before any data
is looked at. This only affects the generated path, so `Converter()` fails
where `BaseConverter()` and `Converter(detailed_validation=False)` succeed.
`NamedTuple`s structure through the same factory, so they crash the same way.

This is the same class of bug as python-attrs#769 and python-attrs#771, and the fix is the same shape
already used in `src/cattrs/gen/typeddicts.py` line 394: build the note text in
Python and embed it with `repr`. The note text is byte-identical to before, so
nothing that reads `IterableValidationNote` changes.

The test covers a quote from `Literal`, the same quote nested inside a `List`,
an `Annotated` whose metadata repr contains both quote characters, and a
`NamedTuple` field, then triggers a real validation failure and checks the note
reads exactly as it does for a tuple whose arguments have no quotes. The
expected note is built from the same type expression so the assertion does not
depend on how a given Python version renders typing objects.
@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 64 untouched benchmarks


Comparing MaxFreedomPollard:escape-hetero-tuple-note (9294bbd) with main (bc34a46)

Open in CodSpeed

@Tinche

Tinche commented Sep 14, 2026

Copy link
Copy Markdown
Member

Ugh :/

Nice find. I suppose this should be released as 26.2.1 sooner rather than later.

@Tinche

Tinche commented Sep 14, 2026

Copy link
Copy Markdown
Member

Thanks!

@Tinche
Tinche merged commit 5bf7c97 into python-attrs:main Sep 14, 2026
14 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.

2 participants