From e21793e90a25c7ea47a9c0369150067cc8322de0 Mon Sep 17 00:00:00 2001 From: Filipe Brandenburger Date: Sun, 17 Nov 2024 03:15:26 -0800 Subject: [PATCH] Allow converter.optional to take a Converter such as converter.pipe as its argument (#1372) * Allow converter.optional to take a converter such as converter.pipe as its argument * Only turn optional into a Converter if needed * Move call to Converter constructor to the end of optional() The constructor consumes __annotations__, so move the constructor call to after those have been set on the optional_converter function * Update tests/test_converters.py * Update tests/test_converters.py --------- Co-authored-by: Hynek Schlawack --- changelog.d/1372.change.md | 1 + src/attr/converters.py | 22 +++++++++++++---- tests/test_converters.py | 50 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1372.change.md diff --git a/changelog.d/1372.change.md b/changelog.d/1372.change.md new file mode 100644 index 00000000..fcb94345 --- /dev/null +++ b/changelog.d/1372.change.md @@ -0,0 +1 @@ +`attrs.converters.optional()` works again when taking `attrs.converters.pipe()` or another Converter as its argument. diff --git a/src/attr/converters.py b/src/attr/converters.py index cac62dda..0a79deef 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -7,7 +7,7 @@ Commonly useful converters. import typing from ._compat import _AnnotationExtractor -from ._make import NOTHING, Factory, pipe +from ._make import NOTHING, Converter, Factory, pipe __all__ = [ @@ -33,10 +33,19 @@ def optional(converter): .. versionadded:: 17.1.0 """ - def optional_converter(val): - if val is None: - return None - return converter(val) + if isinstance(converter, Converter): + + def optional_converter(val, inst, field): + if val is None: + return None + return converter(val, inst, field) + + else: + + def optional_converter(val): + if val is None: + return None + return converter(val) xtr = _AnnotationExtractor(converter) @@ -48,6 +57,9 @@ def optional(converter): if rt: optional_converter.__annotations__["return"] = typing.Optional[rt] + if isinstance(converter, Converter): + return Converter(optional_converter, takes_self=True, takes_field=True) + return optional_converter diff --git a/tests/test_converters.py b/tests/test_converters.py index 164fb0ff..d4aca430 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -143,6 +143,14 @@ class TestOptional: with pytest.raises(ValueError): c("not_an_int") + def test_converter_instance(self): + """ + Works when passed a Converter instance as argument. + """ + c = optional(Converter(to_bool)) + + assert True is c("yes", None, None) + class TestDefaultIfNone: def test_missing_default(self): @@ -272,6 +280,48 @@ class TestPipe: ) +class TestOptionalPipe: + def test_optional(self): + """ + Nothing happens if None. + """ + c = optional(pipe(str, Converter(to_bool), bool)) + + assert None is c.converter(None, None, None) + + def test_pipe(self): + """ + A value is given, run it through all wrapped converters. + """ + c = optional(pipe(str, Converter(to_bool), bool)) + + assert ( + True + is c.converter("True", None, None) + is c.converter(True, None, None) + ) + + def test_instance(self): + """ + Should work when set as an attrib. + """ + + @attr.s + class C: + x = attrib( + converter=optional(pipe(str, Converter(to_bool), bool)), + default=None, + ) + + c1 = C() + + assert None is c1.x + + c2 = C("True") + + assert True is c2.x + + class TestToBool: def test_unhashable(self): """