diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e5d9e265..2040c5e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,7 +29,7 @@ jobs: set -xe python -VV python -m site - python -m pip install --upgrade pip wheel poetry==1.2.2 + python -m pip install --upgrade pip wheel poetry==1.3.2 python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions - name: "Run tox targets for ${{ matrix.python-version }}" run: "python -m tox" @@ -57,7 +57,7 @@ jobs: python-version: "3.9" - name: "Install poetry, check-wheel-content, and twine" - run: "python -m pip install poetry==1.2.2 twine check-wheel-contents" + run: "python -m pip install poetry==1.3.2 twine check-wheel-contents" - name: "Build package" run: "poetry build" - name: "List result" diff --git a/HISTORY.md b/HISTORY.md index 213e5efe..327059bd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ ## 23.1.0 (UNRELEASED) - Introduce the `tagged_union` strategy. ([#318](https://github.com/python-attrs/cattrs/pull/318) [#317](https://github.com/python-attrs/cattrs/issues/317)) +- Introduce the `cattrs.transform_error` helper function for formatting validation exceptions. ([258](https://github.com/python-attrs/cattrs/issues/258) [342](https://github.com/python-attrs/cattrs/pull/342)) - Introduce `override.struct_hook` and `override.unstruct_hook`. Learn more [here](https://catt.rs/en/latest/customizing.html#struct-hook-and-unstruct-hook). [#326](https://github.com/python-attrs/cattrs/pull/326) - Fix generating structuring functions for types with angle brackets (`<>`) and pipe symbols (`|`) in the name. diff --git a/docs/cattrs.preconf.rst b/docs/cattrs.preconf.rst index c7befa22..f51586a2 100644 --- a/docs/cattrs.preconf.rst +++ b/docs/cattrs.preconf.rst @@ -13,7 +13,7 @@ cattrs.preconf.bson module :show-inheritance: cattrs.preconf.cbor2 module --------------------------- +--------------------------- .. automodule:: cattrs.preconf.cbor2 :members: diff --git a/docs/cattrs.rst b/docs/cattrs.rst index 6e8fbdfd..233119be 100644 --- a/docs/cattrs.rst +++ b/docs/cattrs.rst @@ -53,6 +53,14 @@ cattrs.gen module :undoc-members: :show-inheritance: +cattrs.v module +--------------- + +.. automodule:: cattrs.v + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/cattrs.strategies.rst b/docs/cattrs.strategies.rst index e9a3a2da..bce804b2 100644 --- a/docs/cattrs.strategies.rst +++ b/docs/cattrs.strategies.rst @@ -1,17 +1,6 @@ cattrs.strategies package ========================= -Submodules ----------- - -cattrs.strategies.subclasses module ------------------------------------ - -.. automodule:: cattrs.strategies.subclasses - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/preconf.md b/docs/preconf.md index 3e78770b..c6847327 100644 --- a/docs/preconf.md +++ b/docs/preconf.md @@ -88,6 +88,10 @@ When parsing msgpack data from bytes, the library needs to be passed `strict_map ## _cbor2_ +```{versionadded} 23.1.0 + +``` + Found at {mod}`cattrs.preconf.cbor2`. _cbor2_ implements a fully featured CBOR encoder with several extensions for handling shared references, big integers, rational numbers and so on. diff --git a/docs/validation.md b/docs/validation.md index 46768137..385d12ab 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -3,8 +3,11 @@ _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 +## Detailed Validation +```{versionadded} 22.1.0 + +``` In detailed validation mode, any un/structuring errors will be grouped and raised together as a {class}`cattrs.BaseValidationError`, which is a [PEP 654 ExceptionGroup](https://www.python.org/dev/peps/pep-0654/). ExceptionGroups are special exceptions which contain lists of other exceptions, which may themselves be other ExceptionGroups. In essence, ExceptionGroups are trees of exceptions. @@ -23,7 +26,7 @@ class Class: a_list: list[int] a_dict: dict[str, int] ->>> structure({"a_list": ["a"], "a_dict": {"str": "a"}}) +>>> structure({"a_list": ["a"], "a_dict": {"str": "a"}}, Class) + Exception Group Traceback (most recent call last): | File "", line 1, in | File "/Users/tintvrtkovic/pg/cattrs/src/cattr/converters.py", line 276, in structure @@ -63,7 +66,52 @@ class Class: +------------------------------------ ``` -## Non-detailed validation +### Transforming Exceptions into Error Messages + +```{versionadded} 23.1.0 + +``` + +ExceptionGroup stack traces are great while you're developing, but sometimes a more compact representation of validation errors is better. +_cattrs_ provides a helper function, {func}`cattrs.transform_error`, which transforms validation errors into lists of error messages. + +The example from the previous paragraph produces the following error messages: + +```python +>>> from cattrs import transform_error + +>>> try: +... structure({"a_list": ["a"], "a_dict": {"str": "a"}}, Class) +... except Exception as exc: +... print(transform_error(exc)) + +[ + 'invalid value for type, expected int @ $.a_list[0]', + "invalid value for type, expected int @ $.a_dict['str']" +] +``` + +A small number of built-in exceptions are converted into error messages automatically. +This can be further customized by providing {func}`cattrs.transform_error` with a function that it can use to turn individual, non-ExceptionGroup exceptions into error messages. +A useful pattern is wrapping the default, {func}`cattrs.v.format_exception` function. + +``` +>>> from cattrs.v iomport format_exception + +>>> def my_exception_formatter(exc: BaseException, type) -> str: +... if isinstance(exc, MyInterestingException): +... return "My error message" +... return format_exception(exc, type) + +>>> try: +... structure(..., Class) +... except Exception as exc: +... print(transform_error(exc, format_exception=my_exception_formatter)) +``` + +If even more customization is required, {func}`cattrs.transform_error` can be copied over into your codebase and adjusted as needed. + +## Non-detailed Validation Non-detailed validation can be enabled by initializing any of the converters with `detailed_validation=False`. In this mode, any errors during un/structuring will bubble up directly as soon as they happen. diff --git a/poetry.lock b/poetry.lock index 71e8b1d7..e4620629 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -67,32 +67,46 @@ lxml = ["lxml"] [[package]] name = "black" -version = "22.12.0" +version = "23.1.0" description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, - {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, - {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, - {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, - {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, - {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, - {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, - {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, - {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, - {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, - {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, - {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, + {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, + {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, + {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, + {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, + {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, + {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, + {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, + {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, + {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, + {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, + {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, + {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, + {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" +packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} @@ -107,7 +121,7 @@ name = "cbor2" version = "5.4.6" description = "CBOR (de)serializer with extensive tag support" category = "main" -optional = true +optional = false python-versions = ">=3.7" files = [ {file = "cbor2-5.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:309fffbb7f561d67f02095d4b9657b73c9220558701c997e9bfcfbca2696e927"}, @@ -363,14 +377,14 @@ pyflakes = ">=2.5.0,<2.6.0" [[package]] name = "furo" -version = "2022.12.7" +version = "2023.3.27" description = "A clean customisable Sphinx documentation theme." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "furo-2022.12.7-py3-none-any.whl", hash = "sha256:7cb76c12a25ef65db85ab0743df907573d03027a33631f17d267e598ebb191f7"}, - {file = "furo-2022.12.7.tar.gz", hash = "sha256:d8008f8efbe7587a97ba533c8b2df1f9c21ee9b3e5cad0d27f61193d38b1a986"}, + {file = "furo-2023.3.27-py3-none-any.whl", hash = "sha256:4ab2be254a2d5e52792d0ca793a12c35582dd09897228a6dd47885dabd5c9521"}, + {file = "furo-2023.3.27.tar.gz", hash = "sha256:b99e7867a5cc833b2b34d7230631dd6558c7a29f93071fdbb5709634bb33c5a5"}, ] [package.dependencies] @@ -952,6 +966,33 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "psutil" +version = "5.9.4" +description = "Cross-platform lib for process and system monitoring in Python." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "psutil-5.9.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c1ca331af862803a42677c120aff8a814a804e09832f166f226bfd22b56feee8"}, + {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:68908971daf802203f3d37e78d3f8831b6d1014864d7a85937941bb35f09aefe"}, + {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ff89f9b835100a825b14c2808a106b6fdcc4b15483141482a12c725e7f78549"}, + {file = "psutil-5.9.4-cp27-cp27m-win32.whl", hash = "sha256:852dd5d9f8a47169fe62fd4a971aa07859476c2ba22c2254d4a1baa4e10b95ad"}, + {file = "psutil-5.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9120cd39dca5c5e1c54b59a41d205023d436799b1c8c4d3ff71af18535728e94"}, + {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6b92c532979bafc2df23ddc785ed116fced1f492ad90a6830cf24f4d1ea27d24"}, + {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:efeae04f9516907be44904cc7ce08defb6b665128992a56957abc9b61dca94b7"}, + {file = "psutil-5.9.4-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:54d5b184728298f2ca8567bf83c422b706200bcbbfafdc06718264f9393cfeb7"}, + {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16653106f3b59386ffe10e0bad3bb6299e169d5327d3f187614b1cb8f24cf2e1"}, + {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54c0d3d8e0078b7666984e11b12b88af2db11d11249a8ac8920dd5ef68a66e08"}, + {file = "psutil-5.9.4-cp36-abi3-win32.whl", hash = "sha256:149555f59a69b33f056ba1c4eb22bb7bf24332ce631c44a319cec09f876aaeff"}, + {file = "psutil-5.9.4-cp36-abi3-win_amd64.whl", hash = "sha256:fd8522436a6ada7b4aad6638662966de0d61d241cb821239b2ae7013d41a43d4"}, + {file = "psutil-5.9.4-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:6001c809253a29599bc0dfd5179d9f8a5779f9dffea1da0f13c53ee568115e1e"}, + {file = "psutil-5.9.4.tar.gz", hash = "sha256:3d7f9739eb435d4b1338944abe23f49584bde5395f27487d2ee25ad9a8774a62"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + [[package]] name = "py" version = "1.11.0" @@ -1110,6 +1151,24 @@ ocsp = ["certifi", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identit snappy = ["python-snappy"] zstd = ["zstandard"] +[[package]] +name = "pyperf" +version = "2.6.0" +description = "Python module to run and analyze benchmarks" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyperf-2.6.0-py3-none-any.whl", hash = "sha256:3e95511cf0c39d68e9e55716ef1b582e7fc1949ec831ef710566b733b44aebaf"}, + {file = "pyperf-2.6.0.tar.gz", hash = "sha256:d7e367a1ec7035d7a2b25f55a5925596c00cb15851f28cffd85b05b7307232af"}, +] + +[package.dependencies] +psutil = ">=5.9.0" + +[package.extras] +dev = ["importlib-metadata", "tox"] + [[package]] name = "pytest" version = "7.2.1" @@ -1722,4 +1781,4 @@ ujson = ["ujson"] [metadata] lock-version = "2.0" python-versions = ">= 3.7" -content-hash = "dbfdd84b826b04d90a9360116cf5bf568c48e7450ec31847bd748fb2cac7e1c8" +content-hash = "f1917ee3a777dae10bb5db429c4aac4e597011e189c639011298ece395c2eea7" diff --git a/pyproject.toml b/pyproject.toml index 8f64b1a3..fc77ec31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,13 +51,15 @@ pytest-benchmark = "^3.2.3" hypothesis = "^6.54.5" pendulum = "^2.1.2" isort = { version = "5.10.1", python = "<4" } -black = "^22.8.0" +black = "^23.1.0" immutables = "^0.18" -furo = "^2022.9.29" +furo = "^2023.3.27" coverage = "^6.2" urllib3 = { version = "^1.26.12", python = "<4" } sphinx-copybutton = "^0.5.0" myst-parser = "^0.18.1" +cbor2 = "^5.4.6" +pyperf = "^2.6.0" [tool.poetry.urls] "Changelog" = "https://catt.rs/en/latest/history.html" @@ -85,5 +87,4 @@ requires = ["poetry-core>=1.1.0"] build-backend = "poetry.core.masonry.api" [tool.mypy] -plugins = "unionize.py" strict = true diff --git a/src/cattrs/__init__.py b/src/cattrs/__init__.py index f8b8801f..e243d881 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -1,14 +1,18 @@ from .converters import BaseConverter, Converter, GenConverter, UnstructureStrategy from .errors import ( + AttributeValidationNote, BaseValidationError, ClassValidationError, ForbiddenExtraKeysError, IterableValidationError, + IterableValidationNote, StructureHandlerNotFoundError, ) from .gen import override +from .v import transform_error __all__ = ( + "AttributeValidationNote", "BaseConverter", "BaseValidationError", "ClassValidationError", @@ -22,16 +26,18 @@ "GenConverter", "global_converter", "IterableValidationError", + "IterableValidationNote", "override", "preconf", - "register_structure_hook", "register_structure_hook_func", - "register_unstructure_hook", + "register_structure_hook", "register_unstructure_hook_func", - "structure", + "register_unstructure_hook", "structure_attrs_fromdict", "structure_attrs_fromtuple", + "structure", "StructureHandlerNotFoundError", + "transform_error", "unstructure", "UnstructureStrategy", ) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 069c75c7..a3a13c81 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -22,7 +22,11 @@ from attr import has as attrs_has from attr import resolve_types -from cattrs.errors import IterableValidationError, StructureHandlerNotFoundError +from cattrs.errors import ( + IterableValidationError, + IterableValidationNote, + StructureHandlerNotFoundError, +) from ._compat import ( FrozenSetSubscriptable, @@ -507,7 +511,9 @@ def _structure_list(self, obj: Iterable[T], cl: Any) -> List[T]: try: res.append(handler(e, elem_type)) except Exception as e: - msg = f"Structuring {cl} @ index {ix}" + msg = IterableValidationNote( + f"Structuring {cl} @ index {ix}", ix, elem_type + ) e.__notes__ = getattr(e, "__notes__", []) + [msg] errors.append(e) finally: @@ -531,13 +537,20 @@ def _structure_set( if self.detailed_validation: errors = [] res = set() + ix = 0 for e in obj: try: res.add(handler(e, elem_type)) except Exception as exc: - msg = f"Structuring {structure_to.__name__} @ element {e!r}" + msg = IterableValidationNote( + f"Structuring {structure_to.__name__} @ element {e!r}", + ix, + elem_type, + ) exc.__notes__ = getattr(e, "__notes__", []) + [msg] errors.append(exc) + finally: + ix += 1 if errors: raise IterableValidationError(f"While structuring {cl!r}", errors, cl) return res if structure_to is set else structure_to(res) @@ -601,13 +614,18 @@ def _structure_tuple(self, obj: Any, tup: Type[T]) -> T: if self.detailed_validation: errors = [] res = [] - for ix, e in enumerate(obj): + ix = 0 + for e in obj: try: res.append(conv(e, tup_type)) except Exception as exc: - msg = f"Structuring {tup} @ index {ix}" + msg = IterableValidationNote( + f"Structuring {tup} @ index {ix}", ix, tup_type + ) exc.__notes__ = getattr(e, "__notes__", []) + [msg] errors.append(exc) + finally: + ix += 1 if errors: raise IterableValidationError( f"While structuring {tup!r}", errors, tup @@ -633,7 +651,9 @@ def _structure_tuple(self, obj: Any, tup: Type[T]) -> T: conv = self._structure_func.dispatch(t) res.append(conv(e, t)) except Exception as exc: - msg = f"Structuring {tup} @ index {ix}" + msg = IterableValidationNote( + f"Structuring {tup} @ index {ix}", ix, t + ) exc.__notes__ = getattr(e, "__notes__", []) + [msg] errors.append(exc) if len(res) < exp_len: diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 3ce67a2b..720c75e5 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -1,4 +1,4 @@ -from typing import Optional, Set, Type +from typing import Any, List, Optional, Set, Tuple, Type, Union from cattrs._compat import ExceptionGroup @@ -23,16 +23,78 @@ def derive(self, excs): return ClassValidationError(self.message, excs, self.cl) +class IterableValidationNote(str): + """Attached as a note to an exception when an iterable element fails structuring.""" + + index: Union[int, str] # Ints for list indices, strs for dict keys + type: Any + + def __new__( + cls, string: str, index: Union[int, str], type: Any + ) -> "IterableValidationNote": + instance = str.__new__(cls, string) + instance.index = index + instance.type = type + return instance + + class IterableValidationError(BaseValidationError): """Raised when structuring an iterable.""" - pass + def group_exceptions( + self, + ) -> Tuple[List[Tuple[Exception, IterableValidationNote]], List[Exception]]: + """Split the exceptions into two groups: with and without validation notes.""" + excs_with_notes = [] + other_excs = [] + for subexc in self.exceptions: + if hasattr(subexc, "__notes__"): + for note in subexc.__notes__: + if note.__class__ is IterableValidationNote: + excs_with_notes.append((subexc, note)) + break + else: + other_excs.append(subexc) + else: + other_excs.append(subexc) + + return excs_with_notes, other_excs + + +class AttributeValidationNote(str): + """Attached as a note to an exception when an attribute fails structuring.""" + + name: str + type: Any + + def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote": + instance = str.__new__(cls, string) + instance.name = name + instance.type = type + return instance class ClassValidationError(BaseValidationError): """Raised when validating a class if any attributes are invalid.""" - pass + def group_exceptions( + self, + ) -> Tuple[List[Tuple[Exception, AttributeValidationNote]], List[Exception]]: + """Split the exceptions into two groups: with and without validation notes.""" + excs_with_notes = [] + other_excs = [] + for subexc in self.exceptions: + if hasattr(subexc, "__notes__"): + for note in subexc.__notes__: + if note.__class__ is AttributeValidationNote: + excs_with_notes.append((subexc, note)) + break + else: + other_excs.append(subexc) + else: + other_excs.append(subexc) + + return excs_with_notes, other_excs class ForbiddenExtraKeysError(Exception): diff --git a/src/cattrs/gen.py b/src/cattrs/gen.py index f8ae0b7e..93ce7a3c 100644 --- a/src/cattrs/gen.py +++ b/src/cattrs/gen.py @@ -22,9 +22,11 @@ from attr import NOTHING, Attribute, frozen, resolve_types from cattrs.errors import ( + AttributeValidationNote, ClassValidationError, ForbiddenExtraKeysError, IterableValidationError, + IterableValidationNote, StructureHandlerNotFoundError, ) @@ -331,6 +333,7 @@ def make_dict_structure_fn( lines.append(" errors = []") invocation_lines.append("**res,") internal_arg_parts["__c_cve"] = ClassValidationError + internal_arg_parts["__c_avn"] = AttributeValidationNote for a in attrs: an = a.name override = kwargs.get(an, _neutral) @@ -365,6 +368,8 @@ def make_dict_structure_fn( i = f"{i} " lines.append(f"{i}try:") i = f"{i} " + type_name = f"__c_type_{an}" + internal_arg_parts[type_name] = t if handler: if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t @@ -381,7 +386,7 @@ def make_dict_structure_fn( lines.append(f"{i}except Exception as e:") i = f"{i} " lines.append( - f"{i}e.__notes__ = getattr(e, '__notes__', []) + [\"Structuring class {cl.__qualname__} @ attribute {an}\"]" + f'{i}e.__notes__ = getattr(e, \'__notes__\', []) + [__c_avn("Structuring class {cl.__qualname__} @ attribute {an}", "{an}", __c_type_{an})]' ) lines.append(f"{i}errors.append(e)") @@ -701,7 +706,7 @@ def make_mapping_structure_fn( globs: Dict[str, Type] = {"__cattr_mapping_cl": structure_to} lines = [] - lines.append(f"def {fn_name}(mapping, _):") + internal_arg_parts = {} # Let's try fishing out the type args. if not is_bare(cl): @@ -755,15 +760,23 @@ def make_mapping_structure_fn( lines.append(" res = dict(mapping)") else: if detailed_validation: + internal_arg_parts["IterableValidationError"] = IterableValidationError + internal_arg_parts["IterableValidationNote"] = IterableValidationNote + internal_arg_parts["val_type"] = ( + val_type if val_type is not NOTHING else Any + ) + internal_arg_parts["key_type"] = ( + key_type if key_type is not NOTHING else Any + ) globs["enumerate"] = enumerate - globs["IterableValidationError"] = IterableValidationError + lines.append(" res = {}; errors = []") lines.append(" for ix, (k, v) in enumerate(mapping.items()):") lines.append(" try:") lines.append(f" value = {v_s}") lines.append(" except Exception as e:") lines.append( - " e.__notes__ = getattr(e, '__notes__', []) + ['Structuring mapping value @ key ' + repr(k)]" + " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote('Structuring mapping value @ key ' + repr(k), k, val_type)]" ) lines.append(" errors.append(e)") lines.append(" continue") @@ -772,7 +785,7 @@ def make_mapping_structure_fn( lines.append(" res[key] = value") lines.append(" except Exception as e:") lines.append( - " e.__notes__ = getattr(e, '__notes__', []) + ['Structuring mapping key @ key ' + repr(k)]" + " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote('Structuring mapping key @ key ' + repr(k), k, key_type)]" ) lines.append(" errors.append(e)") lines.append(" if errors:") @@ -784,7 +797,14 @@ def make_mapping_structure_fn( if structure_to is not dict: lines.append(" res = __cattr_mapping_cl(res)") - total_lines = lines + [" return res"] + internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) + if internal_arg_line: + internal_arg_line = f", {internal_arg_line}" + for k, v in internal_arg_parts.items(): + globs[k] = v + + def_line = f"def {fn_name}(mapping, _{internal_arg_line}):" + total_lines = [def_line] + lines + [" return res"] script = "\n".join(total_lines) eval(compile(script, "", "exec"), globs) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 67d5cbd6..422de5ff 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -38,7 +38,7 @@ def configure_tagged_union( The tagged union strategy currently only works with the dict un/structuring base strategy. - .. versionadded:: 22.3.0 + .. versionadded:: 23.1.0 """ args = union.__args__ tag_to_hook = {} diff --git a/src/cattrs/v.py b/src/cattrs/v.py new file mode 100644 index 00000000..5be3b5c1 --- /dev/null +++ b/src/cattrs/v.py @@ -0,0 +1,102 @@ +"""Cattrs validation.""" +from typing import Callable, List, Type, Union + +from .errors import ( + ClassValidationError, + ForbiddenExtraKeysError, + IterableValidationError, +) + +__all__ = ["format_exception", "transform_error"] + + +def format_exception(exc: BaseException, type: Union[Type, None]) -> str: + """The default exception formatter, handling the most common exceptions. + + The following exceptions are handled specially: + * `KeyErrors` (`required field missing`) + * `ValueErrors` (`invalid value for type, expected ` or just `invalid value`) + * `TypeErrors` (`invalid value for type, expected ` and a couple special cases for iterables) + * `cattrs.ForbiddenExtraKeysError` + * some `AttributeErrors` (special cased for structing mappings) + """ + if isinstance(exc, KeyError): + res = "required field missing" + elif isinstance(exc, ValueError): + if type is not None: + tn = type.__name__ if hasattr(type, "__name__") else repr(type) + res = f"invalid value for type, expected {tn}" + else: + res = "invalid value" + elif isinstance(exc, TypeError): + if type is None: + if exc.args[0].endswith("object is not iterable"): + res = "invalid value for type, expected an iterable" + else: + res = f"invalid type ({exc})" + else: + tn = type.__name__ if hasattr(type, "__name__") else repr(type) + res = f"invalid value for type, expected {tn}" + elif isinstance(exc, ForbiddenExtraKeysError): + res = f"extra fields found ({', '.join(exc.extra_fields)})" + elif isinstance(exc, AttributeError) and exc.args[0].endswith( + "object has no attribute 'items'" + ): + # This was supposed to be a mapping (and have .items()) but it something else. + res = "expected a mapping" + else: + res = f"unknown error ({exc})" + + return res + + +def transform_error( + exc: Union[ClassValidationError, IterableValidationError, BaseException], + path: str = "$", + format_exception: Callable[ + [BaseException, Union[Type, None]], str + ] = format_exception, +) -> List[str]: + """Transform an exception into a list of error messages. + + To get detailed error messages, the exception should be produced by a converter + with `detailed_validation` set. + + By default, the error messages are in the form of `{description} @ {path}`. + + While traversing the exception and subexceptions, the path is formed: + * by appending `.{field_name}` for fields in classes + * by appending `[{int}]` for indices in iterables, like lists + * by appending `[{str}]` for keys in mappings, like dictionaries + + :param exc: The exception to transform into error messages. + :param path: The root path to use. + :param format_exception: A callable to use to transform `Exceptions` into + string descriptions of errors. + + .. versionadded:: 23.1.0 + """ + errors = [] + if isinstance(exc, IterableValidationError): + with_notes, without = exc.group_exceptions() + for exc, note in with_notes: + p = f"{path}[{note.index!r}]" + if isinstance(exc, (ClassValidationError, IterableValidationError)): + errors.extend(transform_error(exc, p)) + else: + errors.append(f"{format_exception(exc, note.type)} @ {p}") + for exc in without: + errors.append(f"{format_exception(exc, None)} @ {path}") + elif isinstance(exc, ClassValidationError): + with_notes, without = exc.group_exceptions() + for exc, note in with_notes: + p = f"{path}.{note.name}" + if isinstance(exc, (ClassValidationError, IterableValidationError)): + errors.extend(transform_error(exc, p)) + else: + errors.append(f"{format_exception(exc, note.type)} @ {p}") + for exc in without: + errors.append(f"{format_exception(exc, None)} @ {path}") + else: + errors.append(f"{format_exception(exc, None)} @ {path}") + return errors diff --git a/tests/_compat.py b/tests/_compat.py index 0892b130..9b335c69 100644 --- a/tests/_compat.py +++ b/tests/_compat.py @@ -1,22 +1,12 @@ -from cattrs._compat import is_bare, is_py37, is_py38 +from cattrs._compat import is_py37, is_py38 if is_py37 or is_py38: from typing import Dict, List - def change_type_param(cl, new_params): - if is_bare(cl): - return cl[new_params] - return cl.copy_with(new_params) - List_origin = List Dict_origin = Dict else: - - def change_type_param(cl, new_params): - cl.__args__ = (new_params,) - return cl - List_origin = list Dict_origin = dict diff --git a/tests/test_structure.py b/tests/test_structure.py index f604a2a6..9d10f78f 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -24,7 +24,6 @@ from cattrs._compat import copy_with, is_bare, is_union_type from cattrs.errors import IterableValidationError, StructureHandlerNotFoundError -from ._compat import change_type_param from .untyped import ( dicts_of_primitives, enums_of_primitives, @@ -192,7 +191,7 @@ def test_structuring_dicts_opts(dict_and_type, data): converter = BaseConverter() d, t = dict_and_type assume(not is_bare(t)) - t.__args__ = (t.__args__[0], Optional[t.__args__[1]]) + t = copy_with(t, (t.__args__[0], Optional[t.__args__[1]])) d = {k: v if data.draw(booleans()) else None for k, v in d.items()} converted = converter.structure(d, t) @@ -223,7 +222,7 @@ def test_structuring_optional_primitives(primitive_and_type): @given(lists_of_primitives().filter(lambda lp: not is_bare(lp[1])), booleans()) -def test_structuring_lists_of_opt(list_and_type, detailed_validation: bool): +def test_structuring_lists_of_opt(list_and_type, detailed_validation: bool) -> None: """Test structuring lists of Optional primitive types.""" converter = BaseConverter(detailed_validation=detailed_validation) l, t = list_and_type @@ -248,15 +247,13 @@ def test_structuring_lists_of_opt(list_and_type, detailed_validation: bool): optional_t = Optional[args[0]] # We want to create a generic type annotation with an optional # type parameter. - t = change_type_param(t, optional_t) + t = copy_with(t, optional_t) converted = converter.structure(l, t) for x, y in zip(l, converted): assert x == y - t.__args__ = args - @given(lists_of_primitives()) def test_stringifying_lists_of_opt(list_and_type): diff --git a/tests/test_v.py b/tests/test_v.py new file mode 100644 index 00000000..7659115e --- /dev/null +++ b/tests/test_v.py @@ -0,0 +1,227 @@ +"""Tests for the cattrs.v framework.""" +from typing import ( + Dict, + List, + MutableMapping, + MutableSequence, + Optional, + Sequence, + Tuple, +) + +from attrs import Factory, define +from pytest import fixture, raises + +from cattrs import Converter, transform_error +from cattrs._compat import Mapping +from cattrs.gen import make_dict_structure_fn +from cattrs.v import format_exception + + +@fixture +def c() -> Converter: + """We need only converters with detailed_validation=True.""" + return Converter() + + +def test_attribute_errors(c: Converter) -> None: + @define + class C: + a: int + b: int = 0 + + try: + c.structure({}, C) + except Exception as exc: + assert transform_error(exc) == ["required field missing @ $.a"] + + try: + c.structure({"a": 1, "b": "str"}, C) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected int @ $.b"] + + @define + class D: + c: C + + try: + c.structure({}, D) + except Exception as exc: + assert transform_error(exc) == ["required field missing @ $.c"] + + try: + c.structure({"c": {}}, D) + except Exception as exc: + assert transform_error(exc) == ["required field missing @ $.c.a"] + + try: + c.structure({"c": 1}, D) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected C @ $.c"] + + try: + c.structure({"c": {"a": "str"}}, D) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected int @ $.c.a"] + + @define + class E: + a: Optional[int] + + with raises(Exception) as exc: + c.structure({"a": "str"}, E) + + # Complicated due to various Python versions. + tn = ( + Optional[int].__name__ + if hasattr(Optional[int], "__name__") + else repr(Optional[int]) + ) + assert transform_error(exc.value) == [ + f"invalid value for type, expected {tn} @ $.a" + ] + + +def test_class_errors(c: Converter) -> None: + """Errors not directly related to attributes are parsed correctly.""" + + @define + class C: + a: int + b: int = 0 + + c.register_structure_hook( + C, make_dict_structure_fn(C, c, _cattrs_forbid_extra_keys=True) + ) + + try: + c.structure({"d": 1}, C) + except Exception as exc: + assert transform_error(exc) == [ + "required field missing @ $.a", + "extra fields found (d) @ $", + ] + + +def test_sequence_errors(c: Converter) -> None: + try: + c.structure(["str", 1, "str"], List[int]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $[0]", + "invalid value for type, expected int @ $[2]", + ] + + try: + c.structure(1, List[int]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected an iterable @ $" + ] + + try: + c.structure(["str", 1, "str"], Tuple[int, ...]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $[0]", + "invalid value for type, expected int @ $[2]", + ] + + try: + c.structure(["str", 1, "str"], Sequence[int]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $[0]", + "invalid value for type, expected int @ $[2]", + ] + + try: + c.structure(["str", 1, "str"], MutableSequence[int]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $[0]", + "invalid value for type, expected int @ $[2]", + ] + + @define + class C: + a: List[int] + b: List[List[int]] = Factory(list) + + try: + c.structure({"a": ["str", 1, "str"]}, C) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $.a[0]", + "invalid value for type, expected int @ $.a[2]", + ] + + try: + c.structure({"a": [], "b": [[], ["str", 1, "str"]]}, C) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $.b[1][0]", + "invalid value for type, expected int @ $.b[1][2]", + ] + + +def test_mapping_errors(c: Converter) -> None: + try: + c.structure({"a": 1, "b": "str"}, Dict[str, int]) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected int @ $['b']"] + + @define + class C: + a: Dict[str, int] + + try: + c.structure({"a": {"a": "str", "b": 1, "c": "str"}}, C) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $.a['a']", + "invalid value for type, expected int @ $.a['c']", + ] + + try: + c.structure({"a": 1}, C) + except Exception as exc: + assert transform_error(exc) == ["expected a mapping @ $.a"] + + try: + c.structure({"a": 1, "b": "str"}, Mapping[str, int]) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected int @ $['b']"] + + try: + c.structure({"a": 1, "b": "str"}, MutableMapping[str, int]) + except Exception as exc: + assert transform_error(exc) == ["invalid value for type, expected int @ $['b']"] + + try: + c.structure({"a": 1, 2: "str"}, MutableMapping[int, int]) + except Exception as exc: + assert transform_error(exc) == [ + "invalid value for type, expected int @ $['a']", + "invalid value for type, expected int @ $[2]", + ] + + +def test_custom_error_fn(c: Converter) -> None: + def my_format(exc, type): + if isinstance(exc, KeyError): + return "no key" + return format_exception(exc, type) + + @define + class C: + a: int + b: int = 1 + + try: + c.structure({"b": "str"}, C) + except Exception as exc: + assert transform_error(exc, format_exception=my_format) == [ + "no key @ $.a", + "invalid value for type, expected int @ $.b", + ] diff --git a/tests/typed.py b/tests/typed.py index 011e5a15..be28fb0d 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -798,9 +798,7 @@ def _create_hyp_nested_strategy( # A strategy producing tuples of the form ([list of attributes], ). attrs_and_classes: SearchStrategy[ - Tuple[ - List[Tuple[_CountingAttr, PosArgs]], Tuple[Type, SearchStrategy[PosArgs]], - ] + Tuple[List[Tuple[_CountingAttr, PosArgs]], Tuple[Type, SearchStrategy[PosArgs]]] ] = tuples( lists_of_typed_attrs(kw_only=kw_only, newtypes=newtypes), simple_class_strategy )