Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1629.change.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 6 additions & 2 deletions src/attr/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Commonly useful converters.
"""

from typing import Optional

from ._compat import _AnnotationExtractor
from ._make import NOTHING, Converter, Factory, pipe

Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import pickle
import typing

import pytest

Expand Down Expand Up @@ -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):
Expand Down
Loading