From 8b201bc55d1c303811b2168651d8c9d9c2827163 Mon Sep 17 00:00:00 2001 From: boulea7 Date: Wed, 30 Sep 2026 02:44:19 +0800 Subject: [PATCH 1/2] Preserve wrapped converter return annotations in pipe --- docs/init.md | 14 ++++++++++++++ src/attr/_make.py | 3 +-- tests/test_converters.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) 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): From a25520700d01fbafe74c192267888f3d15c98b41 Mon Sep 17 00:00:00 2001 From: boulea7 Date: Wed, 30 Sep 2026 03:07:18 +0800 Subject: [PATCH 2/2] Add news fragment for pipe annotation fix --- changelog.d/1631.change.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1631.change.md 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.