Skip to content

Commit

Permalink
Make attrs.converters.pipe only return a Converter instance if one is…
Browse files Browse the repository at this point in the history
… passed (#1380)

* Make attrs.converters.pipe only return a Converter instance if one is passed

* Add type check overloads to specify that callables will return callables

---------

Co-authored-by: Hynek Schlawack <[email protected]>
  • Loading branch information
filbranden and hynek authored Dec 10, 2024
1 parent 2a76643 commit 1e07f46
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 31 deletions.
26 changes: 21 additions & 5 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -2932,11 +2932,25 @@ def pipe(*converters):
.. versionadded:: 20.1.0
"""

def pipe_converter(val, inst, field):
for c in converters:
val = c(val, inst, field) if isinstance(c, Converter) else c(val)
return_instance = any(isinstance(c, Converter) for c in converters)

return val
if return_instance:

def pipe_converter(val, inst, field):
for c in converters:
val = (
c(val, inst, field) if isinstance(c, Converter) else c(val)
)

return val

else:

def pipe_converter(val):
for c in converters:
val = c(val)

return val

if not converters:
# If the converter list is empty, pipe_converter is the identity.
Expand All @@ -2957,4 +2971,6 @@ def pipe_converter(val, inst, field):
if rt:
pipe_converter.__annotations__["return"] = rt

return Converter(pipe_converter, takes_self=True, takes_field=True)
if return_instance:
return Converter(pipe_converter, takes_self=True, takes_field=True)
return pipe_converter
18 changes: 12 additions & 6 deletions src/attr/converters.pyi
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
from typing import Callable, TypeVar, overload
from typing import Callable, Any, overload

from attrs import _ConverterType

_T = TypeVar("_T")
from attrs import _ConverterType, _CallableConverterType

@overload
def pipe(*validators: _CallableConverterType) -> _CallableConverterType: ...
@overload
def pipe(*validators: _ConverterType) -> _ConverterType: ...
@overload
def optional(converter: _CallableConverterType) -> _CallableConverterType: ...
@overload
def optional(converter: _ConverterType) -> _ConverterType: ...
@overload
def default_if_none(default: _T) -> _ConverterType: ...
def default_if_none(default: Any) -> _CallableConverterType: ...
@overload
def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ...
def default_if_none(
*, factory: Callable[[], Any]
) -> _CallableConverterType: ...
def to_bool(val: str | int | bool) -> bool: ...
3 changes: 2 additions & 1 deletion src/attrs/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ _C = TypeVar("_C", bound=type)

_EqOrderType = bool | Callable[[Any], Any]
_ValidatorType = Callable[[Any, "Attribute[_T]", _T], Any]
_ConverterType = Callable[[Any], Any] | Converter[Any, _T]
_CallableConverterType = Callable[[Any], Any]
_ConverterType = _CallableConverterType | Converter[Any, Any]
_ReprType = Callable[[Any], str]
_ReprArgType = bool | _ReprType
_OnSetAttrType = Callable[[Any, "Attribute[Any]", Any], Any]
Expand Down
30 changes: 14 additions & 16 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,27 +278,25 @@ def strlen(y: str) -> int:
def identity(z):
return z

assert attr.converters.pipe(int2str).converter.__annotations__ == {
assert attr.converters.pipe(int2str).__annotations__ == {
"val": int,
"return": str,
}
assert attr.converters.pipe(
int2str, strlen
).converter.__annotations__ == {
assert attr.converters.pipe(int2str, strlen).__annotations__ == {
"val": int,
"return": int,
}
assert attr.converters.pipe(
identity, strlen
).converter.__annotations__ == {"return": int}
assert attr.converters.pipe(
int2str, identity
).converter.__annotations__ == {"val": int}
assert attr.converters.pipe(identity, strlen).__annotations__ == {
"return": int
}
assert attr.converters.pipe(int2str, identity).__annotations__ == {
"val": int
}

def int2str_(x: int, y: int = 0) -> str:
return str(x)

assert attr.converters.pipe(int2str_).converter.__annotations__ == {
assert attr.converters.pipe(int2str_).__annotations__ == {
"val": int,
"return": str,
}
Expand All @@ -310,19 +308,19 @@ def test_pipe_empty(self):

p = attr.converters.pipe()

assert "val" in p.converter.__annotations__
assert "val" in p.__annotations__

t = p.converter.__annotations__["val"]
t = p.__annotations__["val"]

assert isinstance(t, typing.TypeVar)
assert p.converter.__annotations__ == {"val": t, "return": t}
assert p.__annotations__ == {"val": t, "return": t}

def test_pipe_non_introspectable(self):
"""
pipe() doesn't crash when passed a non-introspectable converter.
"""

assert attr.converters.pipe(print).converter.__annotations__ == {}
assert attr.converters.pipe(print).__annotations__ == {}

def test_pipe_nullary(self):
"""
Expand All @@ -332,7 +330,7 @@ def test_pipe_nullary(self):
def noop():
pass

assert attr.converters.pipe(noop).converter.__annotations__ == {}
assert attr.converters.pipe(noop).__annotations__ == {}

def test_optional(self):
"""
Expand Down
6 changes: 3 additions & 3 deletions tests/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,11 @@ def test_fail(self):

# First wrapped converter fails:
with pytest.raises(ValueError):
c.converter(33, None, None)
c(33)

# Last wrapped converter fails:
with pytest.raises(ValueError):
c.converter("33", None, None)
c("33")

def test_sugar(self):
"""
Expand All @@ -273,7 +273,7 @@ def test_empty(self):
"""
o = object()

assert o is pipe().converter(o, None, None)
assert o is pipe()(o)

def test_wrapped_annotation(self):
"""
Expand Down

0 comments on commit 1e07f46

Please sign in to comment.