diff --git a/changelog.d/1631.change.md b/changelog.d/1631.change.md new file mode 100644 index 000000000..f5f5d4918 --- /dev/null +++ b/changelog.d/1631.change.md @@ -0,0 +1 @@ +`attrs.converters.pipe()` now preserves the return annotation of a final `attrs.Converter` when Python cannot inspect the converter instance's signature, including early Python 3.11 releases. diff --git a/docs/init.md b/docs/init.md index 8d5f2610f..3a5f67901 100644 --- a/docs/init.md +++ b/docs/init.md @@ -386,6 +386,20 @@ If you need more control over the conversion process, you can wrap the converter C(x=410) ``` +Use {func}`attrs.converters.pipe` to apply converters in sequence. +A final {class}`attrs.Converter` preserves the return annotation of its wrapped function. +Tools can inspect that annotation on the pipeline: + +```{doctest} +>>> import inspect +>>> from attrs.converters import pipe +>>> def to_int(value) -> int: +... return int(value) +>>> converter = pipe(str, attrs.Converter(to_int)) +>>> inspect.signature(converter.__call__).return_annotation + +``` + Or as a decorator ```{doctest} diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..6e1a58e54 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -20,7 +20,6 @@ # having the thread-local in the globals here. from . import _compat, _config, setters from ._compat import ( - PY_3_11_PLUS, PY_3_13_PLUS, _AnnotationExtractor, _get_annotations, @@ -3449,7 +3448,7 @@ def pipe_converter(val): pipe_converter.__annotations__["val"] = t last = converters[-1] - if not PY_3_11_PLUS and isinstance(last, Converter): + if isinstance(last, Converter): last = last.__call__ # Get return type from last converter. diff --git a/tests/test_converters.py b/tests/test_converters.py index 5726ae210..8a82b3633 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -4,6 +4,7 @@ Tests for `attr.converters`. """ +import inspect import pickle import pytest @@ -298,6 +299,35 @@ class C: ).get_return_type() ) + def test_wrapped_annotation_when_converter_signature_unavailable( + self, monkeypatch + ): + """ + A pipe keeps the last Converter's return type even if its instance + signature cannot be inspected. + """ + + def last(value) -> bool: + return bool(value) + + wrapped = Converter(last) + original_signature = inspect.signature + + def signature_without_converter_instances(obj, *args, **kwargs): + if isinstance(obj, Converter): + raise TypeError("Converter instance signature unavailable") + + return original_signature(obj, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr( + inspect, "signature", signature_without_converter_instances + ) + converted = pipe(wrapped) + + assert True is converted(5, None, None) + assert bool is inspect.signature(converted.__call__).return_annotation + class TestOptionalPipe: def test_optional(self):