diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..9ddae2f3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- The PyYAML preconf converter no longer fails with an `AttributeError` when unstructuring `str` subclasses that aren't enums (such as `markupsafe.Markup`); they are now unstructured into plain strings. + ([#804](https://github.com/python-attrs/cattrs/pull/804)) - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. diff --git a/src/cattrs/preconf/pyyaml.py b/src/cattrs/preconf/pyyaml.py index 9d8f76ec..c83370a5 100644 --- a/src/cattrs/preconf/pyyaml.py +++ b/src/cattrs/preconf/pyyaml.py @@ -1,6 +1,7 @@ """Preconfigured converters for pyyaml.""" from datetime import date, datetime +from enum import Enum from functools import partial from typing import Any, TypeVar, Union @@ -24,6 +25,15 @@ def validate_date(v: Any, _): return v +def _unstructure_str(v: str) -> Any: + # `safe_dump` rejects str subclasses, so they have to become plain strings. + if v.__class__ is str: + return v + if isinstance(v, Enum): + return v.value + return str(v) + + class PyyamlConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: return safe_dump(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) @@ -43,16 +53,16 @@ def configure_converter(converter: BaseConverter) -> None: Configure the converter for use with the pyyaml library. * frozensets are serialized as lists - * string enums are converted into strings explicitly + * string enums and other str subclasses are converted into strings explicitly * datetimes and dates are validated * typed namedtuples are serialized as lists .. versionchanged:: 24.1.0 Add support for typed namedtuples. + .. versionchanged:: NEXT + Non-enum str subclasses are unstructured into plain strings. """ - converter.register_unstructure_hook( - str, lambda v: v if v.__class__ is str else v.value - ) + converter.register_unstructure_hook(str, _unstructure_str) # datetime inherits from date, so identity unstructure hook used # here to prevent the date unstructure hook running. diff --git a/tests/preconf/test_pyyaml.py b/tests/preconf/test_pyyaml.py index 11341896..ecd1f4fb 100644 --- a/tests/preconf/test_pyyaml.py +++ b/tests/preconf/test_pyyaml.py @@ -82,3 +82,21 @@ class A: assert ( repr(exc_info.value.exceptions[1]) == "ValueError('Expected date, got 1')" ) + + +def test_pyyaml_str_subclass(): + """Non-enum str subclasses are dumped as plain strings.""" + + class MyStr(str): + pass + + @define + class A: + a: str + + converter = make_converter() + + unstructured = converter.unstructure(A(MyStr("x"))) + assert unstructured == {"a": "x"} + assert unstructured["a"].__class__ is str + assert converter.loads(converter.dumps(A(MyStr("x"))), A) == A("x")