From 9294bbd38c7082d4a247ca552317eafe24fcb709 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:39:20 -0400 Subject: [PATCH] Escape the index note when structuring heterogeneous tuples `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 #769 and #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. --- HISTORY.md | 2 ++ src/cattrs/gen/__init__.py | 3 ++- tests/test_tuples.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index cafbeec2..f81acc7a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- Fix heterogeneous tuples and `NamedTuple`s with a member type containing a quote in its `repr`, like `tuple[Literal["a"], int]`, crashing structuring code generation with `SyntaxError`; the index note is now embedded with `repr`. + ([#777](https://github.com/python-attrs/cattrs/pull/777)) - Fix {func}`transform_error ` listing the extra keys of a `ForbiddenExtraKeysError` in set iteration order, which made the message differ between runs; the keys are now sorted, like the error's own `__str__` already sorts them. ([#776](https://github.com/python-attrs/cattrs/pull/776)) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index d5e1772a..43e1559f 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -892,6 +892,7 @@ def make_hetero_tuple_structure_fn( invocation = f"{struct_handler_name}(o[{ix}])" else: invocation = f"{struct_handler_name}(o[{ix}], {type_name})" + note = f"Structuring {cl} @ index {ix}" lines.extend( [ f" if len(o) > {ix}:", @@ -900,7 +901,7 @@ def make_hetero_tuple_structure_fn( " except Exception as e:", ( f" e.__notes__ = [*getattr(e, '__notes__', []), " - f"__c_ivn('Structuring {cl} @ index {ix}', {ix}, {type_name})]" + f"__c_ivn({note!r}, {ix}, {type_name})]" ), " errors.append(e)", ] diff --git a/tests/test_tuples.py b/tests/test_tuples.py index 7ea69072..fcd57306 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -1,6 +1,6 @@ """Tests for tuples of all kinds.""" -from typing import List, NamedTuple, Tuple +from typing import Annotated, List, Literal, NamedTuple, Tuple from attrs import Factory, define from pytest import raises @@ -32,6 +32,33 @@ def test_structuring_invalid_tuples(converter: BaseConverter): converter.structure(["1", 2, "c"], tuple[int, int, int]) +def test_type_names_with_quotes(): + """Types with quote characters in their reprs should work. + + The index note is baked into the generated source, and it used to be + interpolated inside single quotes, so `Literal["a"]` produced invalid + source and raised `SyntaxError` before anything was structured. + """ + + class NT(NamedTuple): + a: Literal["a"] + b: int + + c = Converter() + + assert c.structure(["a", 1], Tuple[Literal["a"], int]) == ("a", 1) + assert c.structure([["a"], 1], Tuple[List[Literal["a"]], int]) == (["a"], 1) + assert c.structure([1, 2], Tuple[Annotated[int, "it's"], int]) == (1, 2) + assert c.structure(["a", 1], NT) == NT("a", 1) + + with raises(IterableValidationError) as exc_info: + c.structure(["b", 1], Tuple[Literal["a"], int]) + + assert exc_info.value.exceptions[0].__notes__ == [ + f"Structuring {Tuple[Literal['a'], int]} @ index 0" + ] + + def test_simple_hetero_tuples(genconverter: Converter): """Simple heterogenous tuples work.