Skip to content

Commit

Permalink
Improve NewTypes (#310)
Browse files Browse the repository at this point in the history
* Improve NewTypes

* Tweak tests

* NewType fix

* Improve docs
  • Loading branch information
Tinche authored Oct 2, 2022
1 parent e792659 commit ed7f86a
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 10 deletions.
2 changes: 1 addition & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ History
* cattrs now supports un/structuring ``kw_only`` fields on attrs classes into/from dictionaries.
(`#247 <https://github.com/python-attrs/cattrs/pull/247>`_)
* `NewTypes <https://docs.python.org/3/library/typing.html#newtype>`_ are now supported by the ``cattrs.Converter``.
(`#255 <https://github.com/python-attrs/cattrs/pull/255>`_, `#94 <https://github.com/python-attrs/cattrs/issues/94>`_)
(`#255 <https://github.com/python-attrs/cattrs/pull/255>`_, `#94 <https://github.com/python-attrs/cattrs/issues/94>`_, `#297 <https://github.com/python-attrs/cattrs/issues/297>`_)
* ``cattrs.Converter`` and ``cattrs.BaseConverter`` can now copy themselves using the ``copy`` method.
(`#284 <https://github.com/python-attrs/cattrs/pull/284>`_)
* PyPy support (and tests, using a minimal Hypothesis profile) restored.
Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,4 @@
"from enum import Enum, unique"
)
autodoc_typehints = "description"
autosectionlabel_prefix_document = True
25 changes: 24 additions & 1 deletion docs/structuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,33 @@ To support arbitrary unions, register a custom structuring hook for the union
`PEP 593`_ annotations (``typing.Annotated[type, ...]``) are supported and are
matched using the first type present in the annotated type.
.. _structuring_newtypes:
``typing.NewType``
~~~~~~~~~~~~~~~~~~
`NewTypes`_ are supported and are structured according to the rules for their underlying type.
Their hooks can also be overriden using :py:attr:`cattrs.Converter.register_structure_hook`.
.. doctest::
>>> from typing import NewType
>>> from datetime import datetime
>>> IsoDate = NewType("IsoDate", datetime)
>>> converter = cattrs.Converter()
>>> converter.register_structure_hook(IsoDate, lambda v, _: datetime.fromisoformat(v))
>>> converter.structure("2022-01-01", IsoDate)
datetime.datetime(2022, 1, 1, 0, 0)
.. versionadded:: 22.2.0
.. seealso:: :ref:`Unstructuring NewTypes. <unstructuring_newtypes>`
.. note::
NewTypes are not supported by the legacy BaseConverter.
``attrs`` classes and dataclasses
---------------------------------
Expand Down Expand Up @@ -481,7 +504,7 @@ Here's a small example showing how to use factory hooks to apply the `forbid_ext
cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else
A complex use case for hook factories is described over at :ref:`Using factory hooks`.
A complex use case for hook factories is described over at :ref:`usage:Using factory hooks`.
.. _`PEP 593` : https://www.python.org/dev/peps/pep-0593/
.. _`NewTypes`: https://docs.python.org/3/library/typing.html#newtype
13 changes: 11 additions & 2 deletions docs/unstructuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,26 @@ from ``typing`` on older Python versions.
Fields marked as ``typing.Annotated[type, ...]`` are supported and are matched
using the first type present in the annotated type.

.. _unstructuring_newtypes:

``typing.NewType``
------------------

`NewTypes`_ are supported and are unstructured according to the rules for their underlying type.
Their hooks can also be overriden using :py:attr:`cattrs.Converter.register_unstructure_hook`.

.. versionadded:: 22.2.0

.. seealso:: :ref:`Structuring NewTypes. <structuring_newtypes>`

.. note::
NewTypes are not supported by the legacy BaseConverter.

``attrs`` classes and dataclasses
---------------------------------

``attrs`` classes and dataclasses are supported out of the box.
:class:`.Converter` s support two unstructuring strategies:
:class:`cattrs.Converter` s support two unstructuring strategies:

* ``UnstructureStrategy.AS_DICT`` - similar to ``attr.asdict``, unstructures ``attrs`` and dataclass instances into dictionaries. This is the default.
* ``UnstructureStrategy.AS_TUPLE`` - similar to ``attr.astuple``, unstructures ``attrs`` and dataclass instances into tuples.
Expand Down Expand Up @@ -205,6 +214,6 @@ Here's a small example showing how to use factory hooks to skip unstructuring
{'an_int': 1}


A complex use case for hook factories is described over at :ref:`Using factory hooks`.
A complex use case for hook factories is described over at :ref:`usage:Using factory hooks`.

.. _`NewTypes`: https://docs.python.org/3/library/typing.html#newtype
2 changes: 1 addition & 1 deletion docs/validation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Validation
==========

`cattrs` has a detailed validation mode since version 2022.1.0, and this mode is enabled by default.
`cattrs` has a detailed validation mode since version 22.1.0, and this mode is enabled by default.
When running under detailed validation, the un/structuring hooks are slightly slower but produce more precise and exhaustive error messages.

Detailed validation
Expand Down
9 changes: 8 additions & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ def register_unstructure_hook(self, cls: Any, func: Callable[[Any], Any]) -> Non
resolve_types(cls)
if is_union_type(cls):
self._unstructure_func.register_func_list([(lambda t: t == cls, func)])
elif get_newtype_base(cls) is not None:
# This is a newtype, so we handle it specially.
self._unstructure_func.register_func_list([(lambda t: t is cls, func)])
else:
self._unstructure_func.register_cls_list([(cls, func)])

Expand Down Expand Up @@ -270,6 +273,9 @@ def register_structure_hook(
if is_union_type(cl):
self._union_struct_registry[cl] = func
self._structure_func.clear_cache()
elif get_newtype_base(cl) is not None:
# This is a newtype, so we handle it specially.
self._structure_func.register_func_list([(lambda t: t is cl, func)])
else:
self._structure_func.register_cls_list([(cl, func)])

Expand Down Expand Up @@ -831,7 +837,8 @@ def __init__(

def get_structure_newtype(self, type: Type[T]) -> Callable[[Any, Any], T]:
base = get_newtype_base(type)
return self._structure_func.dispatch(base)
handler = self._structure_func.dispatch(base)
return lambda v, _: handler(v, base)

def gen_unstructure_annotated(self, type):
origin = type.__origin__
Expand Down
9 changes: 5 additions & 4 deletions src/cattrs/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,18 @@ def register_cls_list(self, cls_and_handler, direct: bool = False) -> None:

def register_func_list(
self,
func_and_handler: List[
pred_and_handler: List[
Union[
Tuple[Callable[[Any], bool], Any],
Tuple[Callable[[Any], bool], Any, bool],
]
],
):
"""register a function to determine if the handle
should be used for the type
"""
for tup in func_and_handler:
Register a predicate function to determine if the handle
should be used for the type.
"""
for tup in pred_and_handler:
if len(tup) == 2:
func, handler = tup
self._function_dispatch.register(func, handler)
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from cattrs import BaseConverter, Converter


@pytest.fixture(params=(True, False))
def genconverter(request):
return Converter(detailed_validation=request.param)


@pytest.fixture(params=(True, False))
def converter(request, converter_cls):
return converter_cls(detailed_validation=request.param)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_newtypes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for NewTypes."""
from typing import NewType

import pytest

from cattrs import BaseConverter

PositiveIntNewType = NewType("PositiveIntNewType", int)
BigPositiveIntNewType = NewType("BigPositiveIntNewType", PositiveIntNewType)


def test_newtype_structure_hooks(genconverter: BaseConverter):
"""NewTypes should work with `register_structure_hook`."""

assert genconverter.structure("0", int) == 0
assert genconverter.structure("0", PositiveIntNewType) == 0
assert genconverter.structure("0", BigPositiveIntNewType) == 0

genconverter.register_structure_hook(
PositiveIntNewType, lambda v, _: int(v) if int(v) > 0 else 1 / 0
)

with pytest.raises(ZeroDivisionError):
genconverter.structure("0", PositiveIntNewType)

assert genconverter.structure("1", PositiveIntNewType) == 1

with pytest.raises(ZeroDivisionError):
genconverter.structure("0", BigPositiveIntNewType)

genconverter.register_structure_hook(
BigPositiveIntNewType, lambda v, _: int(v) if int(v) > 50 else 1 / 0
)

with pytest.raises(ZeroDivisionError):
genconverter.structure("1", BigPositiveIntNewType)

assert genconverter.structure("1", PositiveIntNewType) == 1
assert genconverter.structure("51", BigPositiveIntNewType) == 51


def test_newtype_unstructure_hooks(genconverter: BaseConverter):
"""NewTypes should work with `register_unstructure_hook`."""

assert genconverter.unstructure(0, int) == 0
assert genconverter.unstructure(0, PositiveIntNewType) == 0
assert genconverter.unstructure(0, BigPositiveIntNewType) == 0

genconverter.register_unstructure_hook(PositiveIntNewType, oct)

assert genconverter.unstructure(0, PositiveIntNewType) == "0o0"
assert genconverter.unstructure(0, BigPositiveIntNewType) == "0o0"

genconverter.register_unstructure_hook(BigPositiveIntNewType, hex)

assert genconverter.unstructure(0, PositiveIntNewType) == "0o0"
assert genconverter.unstructure(0, BigPositiveIntNewType) == "0x0"

0 comments on commit ed7f86a

Please sign in to comment.