Skip to content

fix: support string annotations in attrs.converters.optional - #1629

Open
januththedev wants to merge 1 commit into
python-attrs:mainfrom
januththedev:fix/optional-string-annotations
Open

januththedev wants to merge 1 commit into
python-attrs:mainfrom
januththedev:fix/optional-string-annotations

Conversation

@januththedev

Copy link
Copy Markdown

attrs.converters.optional() crashes on string annotations

Description

optional() infers the wrapped converter's annotations and then unions them with None:

t = xtr.get_first_param_type()
if t:
    optional_converter.__annotations__["val"] = t | None

get_first_param_type() and get_return_type() read inspect.signature(), which returns the annotation as a string when it is a quoted forward reference, or when the converter's own module uses from __future__ import annotations (PEP 563). "int" | None raises TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'.

typing.Optional["int"] handles strings correctly by wrapping them in a ForwardRef.

Why this is a bug

optional()'s documented contract is "Type annotations will be inferred from the wrapped converter's, if they have any." A perfectly ordinary, valid converter — one written with quoted forward references, or in a PEP 563 module — makes the construction of the converter crash with a TypeError from library internals, before it is ever called. The failure has nothing to do with the user's value.

It is also a regression: 26.1.0 used typing.Optional[t], and the current dev cycle replaced it with t | None as a Python 3.10+ modernization. The two are only equivalent for real type objects. Ruff's UP045 is what pushes that rewrite, and it does not consider string operands.

Reproduction

from attrs import converters

def to_int(x: "int") -> "int":
    return int(x)

converters.optional(to_int)
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'

The change

-        optional_converter.__annotations__["val"] = t | None
+        # t can be a string -- because of a quoted forward reference or
+        # `from __future__ import annotations` -- and `str | None` blows up.
+        optional_converter.__annotations__["val"] = Optional[t]  # noqa: UP045

and the same for the return annotation. Optional[t] == t | None for real type objects, so every existing code path is unchanged; only the previously-crashing string case is restored. The # noqa: UP045 is required because the modernizer would otherwise reintroduce the very rewrite that causes the bug.

Tests

TestOptional::test_string_annotations builds a converter with quoted annotations, asserts the wrapped converter's annotations are typing.Optional[typing.ForwardRef("int")], and checks it still round-trips a value and accepts None.

  • Before: TypeError: unsupported operand type(s) for |: 'str' and 'NoneType' at src/attr/converters.py:52. After: passes.
  • pytest tests/test_converters.py -q → 39 passed.
  • pytest tests/test_converters.py tests/test_annotations.py -q → 87 passed, 2 skipped. The existing typing.Optional[int] assertions in test_annotations.py still pass, which is what demonstrates the change is behaviour-preserving for real types.
  • Full suite: 1488 passed, 11 skipped, 1 xfailed, 0 failures. The baseline on unmodified HEAD was 1487 passed with 0 failures, so the delta is exactly this one test.
  • ruff check and ruff format --check clean.

I fetched src/attr/converters.py from main and it still contains the identical t | None lines, so this is unfixed upstream. I checked the open issues and PRs: #1348 and #831 are both closed and unrelated, and #1619/#1598 concern __init__ forward refs.

@januththedev
januththedev force-pushed the fix/optional-string-annotations branch from b0ddd1d to 62a32ee Compare September 27, 2026 16:21

This branch has not been deployed

No deployments
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