From 62a32ee301d685934e6529f590771125c41f2ed8 Mon Sep 17 00:00:00 2001 From: Januth Nimnal Date: Sun, 27 Sep 2026 21:45:30 +0530 Subject: [PATCH] fix: support string annotations in attrs.converters.optional --- changelog.d/1629.change.md | 1 + src/attr/converters.py | 8 ++++++-- tests/test_converters.py | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1629.change.md diff --git a/changelog.d/1629.change.md b/changelog.d/1629.change.md new file mode 100644 index 000000000..0e7e1deb2 --- /dev/null +++ b/changelog.d/1629.change.md @@ -0,0 +1 @@ +`attrs.converters.optional()` no longer raises a `TypeError` if the wrapped converter has string annotations, as created by quoted forward references or by using `from __future__ import annotations`. diff --git a/src/attr/converters.py b/src/attr/converters.py index 0c3f0ead4..4731c3f17 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -4,6 +4,8 @@ Commonly useful converters. """ +from typing import Optional + from ._compat import _AnnotationExtractor from ._make import NOTHING, Converter, Factory, pipe @@ -49,11 +51,13 @@ def optional_converter(val): t = xtr.get_first_param_type() if t: - 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 rt = xtr.get_return_type() if rt: - optional_converter.__annotations__["return"] = rt | None + optional_converter.__annotations__["return"] = Optional[rt] # noqa: UP045 if isinstance(converter, Converter): return Converter(optional_converter, takes_self=True, takes_field=True) diff --git a/tests/test_converters.py b/tests/test_converters.py index 5726ae210..c57b7f8a2 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -5,6 +5,7 @@ """ import pickle +import typing import pytest @@ -170,6 +171,25 @@ def test_converter_instance(self): assert True is c("yes", None, None) + def test_string_annotations(self): + """ + Converters whose annotations are strings -- because of quoted forward + references or because of `from __future__ import annotations` -- are + wrapped in `Optional` instead of blowing up. + """ + + def to_int(x: "int") -> "int": + return int(x) + + c = optional(to_int) + + assert 42 == c("42") + assert { + # Optional[] is the point: `t | None` crashes on strings. + "val": typing.Optional[typing.ForwardRef("int")], # noqa: UP045 + "return": typing.Optional[typing.ForwardRef("int")], # noqa: UP045 + } == c.__annotations__ + class TestDefaultIfNone: def test_missing_default(self):