diff --git a/HISTORY.md b/HISTORY.md index ebcf92ba..bc2aed0d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,8 +2,13 @@ ## 24.1.0 (UNRELEASED) +- Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases. + ([#452](https://github.com/python-attrs/cattrs/pull/452)) - More robust support for `Annotated` and `NotRequired` in TypedDicts. ([#450](https://github.com/python-attrs/cattrs/pull/450)) +- [PEP 695](https://peps.python.org/pep-0695/) generics are now tested. + ([#452](https://github.com/python-attrs/cattrs/pull/452)) +- _cattrs_ now uses Ruff for sorting imports. ## 23.2.1 (2023-11-18) diff --git a/Makefile b/Makefile index c1c930dd..f89ec9fa 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,6 @@ clean-test: ## remove test and coverage artifacts lint: ## check style with ruff and black pdm run ruff src/ tests - pdm run isort -c src/ tests pdm run black --check src tests docs/conf.py test: ## run tests quickly with the default Python diff --git a/README.md b/README.md index 505fe127..42ccaeee 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,7 @@ _cattrs_ works well with _attrs_ classes out of the box. C(a=1, b='a') ``` -Here's a much more complex example, involving `attrs` classes with type -metadata. +Here's a much more complex example, involving _attrs_ classes with type metadata. ```python >>> from enum import unique, Enum @@ -99,12 +98,9 @@ metadata. [Dog(cuteness=1, chip=DogMicrochip(chip_id=1, time_chipped=10.0)), Cat(breed=, names=['Fluffly', 'Fluffer'])] ``` -Consider unstructured data a low-level representation that needs to be converted -to structured data to be handled, and use `structure`. When you're done, -`unstructure` the data to its unstructured form and pass it along to another -library or module. Use [attrs type metadata](http://attrs.readthedocs.io/en/stable/examples.html#types) -to add type metadata to attributes, so _cattrs_ will know how to structure and -destructure them. +Consider unstructured data a low-level representation that needs to be converted to structured data to be handled, and use `structure`. +When you're done, `unstructure` the data to its unstructured form and pass it along to another library or module. +Use [attrs type metadata](http://attrs.readthedocs.io/en/stable/examples.html#types) to add type metadata to attributes, so _cattrs_ will know how to structure and destructure them. - Free software: MIT license - Documentation: https://catt.rs @@ -116,12 +112,11 @@ destructure them. - _attrs_ classes and dataclasses are converted into dictionaries in a way similar to `attrs.asdict`, or into tuples in a way similar to `attrs.astuple`. - Enumeration instances are converted to their values. - - Other types are let through without conversion. This includes types such as - integers, dictionaries, lists and instances of non-_attrs_ classes. + - Other types are let through without conversion. This includes types such as integers, dictionaries, lists and instances of non-_attrs_ classes. - Custom converters for any type can be registered using `register_unstructure_hook`. -- Converts unstructured data into structured data, recursively, according to - your specification given as a type. The following types are supported: +- Converts unstructured data into structured data, recursively, according to your specification given as a type. + The following types are supported: - `typing.Optional[T]`. - `typing.List[T]`, `typing.MutableSequence[T]`, `typing.Sequence[T]` (converts to a list). @@ -129,15 +124,15 @@ destructure them. - `typing.MutableSet[T]`, `typing.Set[T]` (converts to a set). - `typing.FrozenSet[T]` (converts to a frozenset). - `typing.Dict[K, V]`, `typing.MutableMapping[K, V]`, `typing.Mapping[K, V]` (converts to a dict). - - `typing.TypedDict`. + - `typing.TypedDict`, ordinary and generic. - _attrs_ classes with simple attributes and the usual `__init__`. - Simple attributes are attributes that can be assigned unstructured data, like numbers, strings, and collections of unstructured data. - All _attrs_ classes and dataclasses with the usual `__init__`, if their complex attributes have type metadata. - - `typing.Union` s of supported _attrs_ classes, given that all of the classes have a unique field. - - `typing.Union` s of anything, given that you provide a disambiguation function for it. + - Unions of supported _attrs_ classes, given that all of the classes have a unique field. + - Unions s of anything, given that you provide a disambiguation function for it. - Custom converters for any type can be registered using `register_structure_hook`. _cattrs_ comes with preconfigured converters for a number of serialization libraries, including json, msgpack, cbor2, bson, yaml and toml. diff --git a/docs/structuring.md b/docs/structuring.md index 06579718..969bb941 100644 --- a/docs/structuring.md +++ b/docs/structuring.md @@ -467,11 +467,11 @@ A(a='string', b=2) Structuring from tuples can also be made the default for specific classes only; see registering custom structure hooks below. -## Using Attribute Types and Converters +### Using Attribute Types and Converters By default, {meth}`structure() ` will use hooks registered using {meth}`register_structure_hook() `, to convert values to the attribute type, and fallback to invoking any converters registered on -attributes with `attrib`. +attributes with `field`. ```{doctest} @@ -494,8 +494,6 @@ but this priority can be inverted by setting `prefer_attrib_converters` to `True >>> converter = cattrs.Converter(prefer_attrib_converters=True) ->>> converter.register_structure_hook(int, lambda v, t: int(v)) - >>> @define ... class A: ... a: int = field(converter=lambda v: int(v) + 5) @@ -504,15 +502,10 @@ but this priority can be inverted by setting `prefer_attrib_converters` to `True A(a=15) ``` -### Complex `attrs` Classes and Dataclasses - -Complex `attrs` classes and dataclasses are classes with type information -available for some or all attributes. These classes support almost arbitrary -nesting. +### Complex _attrs_ Classes and Dataclasses -Type information is supported by attrs directly, and can be set using type -annotations when using Python 3.6+, or by passing the appropriate type to -`attr.ib`. +Complex _attrs_ classes and dataclasses are classes with type information available for some or all attributes. +These classes support almost arbitrary nesting. ```{doctest} @@ -520,12 +513,11 @@ annotations when using Python 3.6+, or by passing the appropriate type to ... class A: ... a: int ->>> attr.fields(A).a +>>> attrs.fields(A).a Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a') ``` -Type information, when provided, can be used for all attribute types, not only -attributes holding `attrs` classes and dataclasses. +Type information can be used for all attribute types, not only attributes holding _attrs_ classes and dataclasses. ```{doctest} @@ -541,13 +533,23 @@ attributes holding `attrs` classes and dataclasses. B(b=A(a=1)) ``` -Finally, if an `attrs` or `dataclass` class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy. +Generic _attrs_ classes and dataclasses are fully supported, both using `typing.Generic` and [PEP 695](https://peps.python.org/pep-0695/). + +```python +>>> @define +... class A[T]: +... a: T + +>>> cattrs.structure({"a": "1"}, A[int]) +A(a=1) +``` + +Finally, if an _attrs_ or dataclass class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy. ## Registering Custom Structuring Hooks -_cattrs_ doesn't know how to structure non-_attrs_ classes by default, -so it has to be taught. This can be done by registering structuring hooks on -a converter instance (including the global converter). +_cattrs_ doesn't know how to structure non-_attrs_ classes by default, so it has to be taught. +This can be done by registering structuring hooks on a converter instance (including the global converter). Here's an example involving a simple, classic (i.e. non-_attrs_) Python class. @@ -569,8 +571,7 @@ StructureHandlerNotFoundError: Unsupported type: . Register C(a=1) ``` -The structuring hooks are callables that take two arguments: the object to -convert to the desired class and the type to convert to. +The structuring hooks are callables that take two arguments: the object to convert to the desired class and the type to convert to. (The type may seem redundant but is useful when dealing with generic types.) When using {meth}`cattrs.register_structure_hook`, the hook will be registered on the global converter. diff --git a/pdm.lock b/pdm.lock index 26f49909..e2beac9a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "bench", "bson", "cbor2", "docs", "lint", "msgpack", "orjson", "pyyaml", "test", "tomlkit", "ujson"] strategy = ["cross_platform"] lock_version = "4.4" -content_hash = "sha256:ae80bb05246f0b5d6054f2e90df4f7c3cd39696694c48608cf4592341550409e" +content_hash = "sha256:ef67dba9400ae655c15640f489c30128ac6b265e154c2cb49eddc81828ba8f9b" [[package]] name = "alabaster" @@ -55,7 +55,7 @@ files = [ [[package]] name = "black" -version = "23.7.0" +version = "23.11.0" requires_python = ">=3.8" summary = "The uncompromising code formatter." dependencies = [ @@ -65,31 +65,27 @@ dependencies = [ "pathspec>=0.9.0", "platformdirs>=2", "tomli>=1.1.0; python_version < \"3.11\"", - "typing-extensions>=3.10.0.0; python_version < \"3.10\"", -] -files = [ - {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, - {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, - {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, - {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, - {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, - {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, - {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, - {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, - {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, - {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, - {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, + "typing-extensions>=4.0.1; python_version < \"3.11\"", +] +files = [ + {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, + {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, + {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, + {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, + {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, + {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, + {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, + {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, + {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, + {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, + {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, + {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, + {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, + {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, + {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, + {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, + {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, + {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, ] [[package]] @@ -446,16 +442,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "isort" -version = "5.12.0" -requires_python = ">=3.8.0" -summary = "A Python utility / library to sort Python imports." -files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, -] - [[package]] name = "jinja2" version = "3.1.2" @@ -1015,27 +1001,27 @@ files = [ [[package]] name = "ruff" -version = "0.0.286" +version = "0.1.6" requires_python = ">=3.7" -summary = "An extremely fast Python linter, written in Rust." -files = [ - {file = "ruff-0.0.286-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:8e22cb557e7395893490e7f9cfea1073d19a5b1dd337f44fd81359b2767da4e9"}, - {file = "ruff-0.0.286-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:68ed8c99c883ae79a9133cb1a86d7130feee0397fdf5ba385abf2d53e178d3fa"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8301f0bb4ec1a5b29cfaf15b83565136c47abefb771603241af9d6038f8981e8"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acc4598f810bbc465ce0ed84417ac687e392c993a84c7eaf3abf97638701c1ec"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88c8e358b445eb66d47164fa38541cfcc267847d1e7a92dd186dddb1a0a9a17f"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0433683d0c5dbcf6162a4beb2356e820a593243f1fa714072fec15e2e4f4c939"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddb61a0c4454cbe4623f4a07fef03c5ae921fe04fede8d15c6e36703c0a73b07"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47549c7c0be24c8ae9f2bce6f1c49fbafea83bca80142d118306f08ec7414041"}, - {file = "ruff-0.0.286-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559aa793149ac23dc4310f94f2c83209eedb16908a0343663be19bec42233d25"}, - {file = "ruff-0.0.286-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d73cfb1c3352e7aa0ce6fb2321f36fa1d4a2c48d2ceac694cb03611ddf0e4db6"}, - {file = "ruff-0.0.286-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3dad93b1f973c6d1db4b6a5da8690c5625a3fa32bdf38e543a6936e634b83dc3"}, - {file = "ruff-0.0.286-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26afc0851f4fc3738afcf30f5f8b8612a31ac3455cb76e611deea80f5c0bf3ce"}, - {file = "ruff-0.0.286-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9b6b116d1c4000de1b9bf027131dbc3b8a70507788f794c6b09509d28952c512"}, - {file = "ruff-0.0.286-py3-none-win32.whl", hash = "sha256:556e965ac07c1e8c1c2d759ac512e526ecff62c00fde1a046acb088d3cbc1a6c"}, - {file = "ruff-0.0.286-py3-none-win_amd64.whl", hash = "sha256:5d295c758961376c84aaa92d16e643d110be32add7465e197bfdaec5a431a107"}, - {file = "ruff-0.0.286-py3-none-win_arm64.whl", hash = "sha256:1d6142d53ab7f164204b3133d053c4958d4d11ec3a39abf23a40b13b0784e3f0"}, - {file = "ruff-0.0.286.tar.gz", hash = "sha256:f1e9d169cce81a384a26ee5bb8c919fe9ae88255f39a1a69fd1ebab233a85ed2"}, +summary = "An extremely fast Python linter and code formatter, written in Rust." +files = [ + {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"}, + {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"}, + {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"}, + {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"}, + {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"}, + {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"}, + {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"}, + {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index b441f130..79078427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,8 @@ [tool.black] skip-magic-trailing-comma = true -[tool.isort] -profile = "black" -known_first_party = ["cattr"] - -[tool.hatch.build.targets.wheel] -packages = ["src/cattr", "src/cattrs"] - - [tool.pdm.dev-dependencies] lint = [ - "isort>=5.11.5", "black>=23.3.0", "ruff>=0.0.277", ] @@ -139,6 +130,7 @@ select = [ "PLC", # Pylint "PIE", # flake8-pie "RUF", # ruff + "I", # isort ] ignore = [ "E501", # line length is handled by black @@ -157,5 +149,8 @@ ignore = [ source = "vcs" raw-options = { local_scheme = "no-local-version" } +[tool.hatch.build.targets.wheel] +packages = ["src/cattr", "src/cattrs"] + [tool.check-wheel-contents] toplevel = ["cattr", "cattrs"] diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 4d01dd41..a2bcc495 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -2,30 +2,45 @@ from collections import deque from collections.abc import MutableSet as AbcMutableSet from collections.abc import Set as AbcSet -from dataclasses import MISSING +from dataclasses import MISSING, is_dataclass from dataclasses import fields as dataclass_fields -from dataclasses import is_dataclass from typing import AbstractSet as TypingAbstractSet -from typing import Any, Deque, Dict, Final, FrozenSet, List, Literal +from typing import ( + Any, + Deque, + Dict, + Final, + FrozenSet, + List, + Literal, + NewType, + Optional, + Protocol, + Tuple, + get_args, + get_origin, + get_type_hints, +) from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence from typing import MutableSet as TypingMutableSet -from typing import NewType, Optional, Protocol from typing import Sequence as TypingSequence from typing import Set as TypingSet -from typing import Tuple, get_args, get_origin, get_type_hints -from attrs import NOTHING, Attribute, Factory +from attrs import NOTHING, Attribute, Factory, resolve_types from attrs import fields as attrs_fields -from attrs import resolve_types __all__ = [ + "adapted_fields", "ExceptionGroup", "ExtensionsTypedDict", - "TypedDict", - "TypeAlias", + "get_type_alias_base", + "has", + "is_type_alias", "is_typeddict", + "TypeAlias", + "TypedDict", ] try: @@ -57,6 +72,16 @@ def is_typeddict(cls): return _is_typeddict(getattr(cls, "__origin__", cls)) +def is_type_alias(type: Any) -> bool: + """Is this a PEP 695 type alias?""" + return False + + +def get_type_alias_base(type: Any) -> Any: + """What is this a type alias of?""" + raise Exception("Runtime type aliases not supported") + + def has(cls): return hasattr(cls, "__attrs_attrs__") or hasattr(cls, "__dataclass_fields__") @@ -157,9 +182,8 @@ def get_final_base(type) -> Optional[type]: from collections.abc import Sequence as AbcSequence from collections.abc import Set as AbcSet from types import GenericAlias - from typing import Annotated - from typing import Counter as TypingCounter from typing import ( + Annotated, Generic, TypedDict, Union, @@ -168,6 +192,7 @@ def get_final_base(type) -> Optional[type]: _SpecialGenericAlias, _UnionGenericAlias, ) + from typing import Counter as TypingCounter try: # Not present on 3.9.0, so we try carefully. @@ -201,6 +226,17 @@ def is_tuple(type): or (getattr(type, "__origin__", None) is tuple) ) + if sys.version_info >= (3, 12): + from typing import TypeAliasType + + def is_type_alias(type: Any) -> bool: # noqa: F811 + """Is this a PEP 695 type alias?""" + return isinstance(type, TypeAliasType) + + def get_type_alias_base(type: Any) -> Any: # noqa: F811 + """What is this a type alias of?""" + return type.__value__ + if sys.version_info >= (3, 10): def is_union_type(obj): @@ -349,6 +385,7 @@ def get_full_type_hints(obj, globalns=None, localns=None): return get_type_hints(obj, globalns, localns, include_extras=True) else: + # 3.8 Set = TypingSet AbstractSet = TypingAbstractSet MutableSet = TypingMutableSet @@ -466,5 +503,6 @@ def get_full_type_hints(obj, globalns=None, localns=None): return get_type_hints(obj, globalns, localns) -def is_generic_attrs(type): +def is_generic_attrs(type) -> bool: + """Return True for both specialized (A[int]) and unspecialized (A) generics.""" return is_generic(type) and has(type.__origin__) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 9b2b99cd..4b9bf6e6 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -18,9 +18,8 @@ Union, ) -from attrs import Attribute +from attrs import Attribute, resolve_types from attrs import has as attrs_has -from attrs import resolve_types from ._compat import ( FrozenSetSubscriptable, @@ -35,6 +34,7 @@ get_final_base, get_newtype_base, get_origin, + get_type_alias_base, has, has_with_generic, is_annotated, @@ -51,6 +51,7 @@ is_protocol, is_sequence, is_tuple, + is_type_alias, is_typeddict, is_union_type, ) @@ -179,6 +180,11 @@ def __init__( lambda t: self._unstructure_func.dispatch(get_final_base(t)), True, ), + ( + is_type_alias, + lambda t: self._unstructure_func.dispatch(get_type_alias_base(t)), + True, + ), (is_mapping, self._unstructure_mapping), (is_sequence, self._unstructure_seq), (is_mutable_set, self._unstructure_seq), @@ -198,6 +204,7 @@ def __init__( (lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v), (is_generic_attrs, self._gen_structure_generic, True), (lambda t: get_newtype_base(t) is not None, self._structure_newtype), + (is_type_alias, self._find_type_alias_structure_hook, True), ( lambda t: get_final_base(t) is not None, self._structure_final_factory, @@ -453,13 +460,18 @@ def _structure_newtype(self, val, type): base = get_newtype_base(type) return self._structure_func.dispatch(base)(val, base) + def _find_type_alias_structure_hook(self, type: Any) -> StructureHook: + base = get_type_alias_base(type) + res = self._structure_func.dispatch(base) + if res == self._structure_call: + # we need to replace the type arg of `structure_call` + return lambda v, _, __base=base: self._structure_call(v, __base) + return res + def _structure_final_factory(self, type): base = get_final_base(type) res = self._structure_func.dispatch(base) - if res == self._structure_call: - # It's not really `structure_call` for Finals (can't call Final()) - return lambda v, _: self._structure_call(v, base) - return lambda v, _: res(v, base) + return lambda v, _, __base=base: res(v, __base) # Attrs classes. diff --git a/tests/_compat.py b/tests/_compat.py index cf9d5764..1636df0d 100644 --- a/tests/_compat.py +++ b/tests/_compat.py @@ -4,6 +4,7 @@ is_py39_plus = sys.version_info >= (3, 9) is_py310_plus = sys.version_info >= (3, 10) is_py311_plus = sys.version_info >= (3, 11) +is_py312_plus = sys.version_info >= (3, 12) if is_py38: from typing import Dict, List diff --git a/tests/conftest.py b/tests/conftest.py index 69d978ca..c56dcd2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import sys from os import environ import pytest @@ -27,3 +28,6 @@ def converter_cls(request): settings.register_profile("fast", settings.get_profile("tests"), max_examples=10) settings.load_profile("fast" if "FAST" in environ else "tests") + +if sys.version_info < (3, 12): + collect_ignore_glob = ["*_695.py"] diff --git a/tests/test_pep_695.py b/tests/test_pep_695.py new file mode 100644 index 00000000..f62abf97 --- /dev/null +++ b/tests/test_pep_695.py @@ -0,0 +1,74 @@ +"""Tests for PEP 695 (Type Parameter Syntax).""" +from dataclasses import dataclass + +import pytest +from attrs import define + +from cattrs import BaseConverter, Converter + +from ._compat import is_py312_plus + + +@pytest.mark.skipif(not is_py312_plus, reason="3.12+ syntax") +def test_simple_generic_roundtrip(converter: BaseConverter): + """PEP 695 attrs generics work.""" + + @define + class A[T]: + a: T + + assert converter.structure({"a": "1"}, A[int]) == A(1) + assert converter.unstructure(A(1)) == {"a": 1} + + if isinstance(converter, Converter): + # Only supported on a Converter + assert converter.unstructure(A(1), A[int]) == {"a": 1} + + +@pytest.mark.skipif(not is_py312_plus, reason="3.12+ syntax") +def test_simple_generic_roundtrip_dc(converter: BaseConverter): + """PEP 695 dataclass generics work.""" + + @dataclass + class A[T]: + a: T + + assert converter.structure({"a": "1"}, A[int]) == A(1) + assert converter.unstructure(A(1)) == {"a": 1} + + if isinstance(converter, Converter): + # Only supported on a Converter + assert converter.unstructure(A(1), A[int]) == {"a": 1} + + +def test_type_aliases(converter: BaseConverter): + """PEP 695 type aliases work.""" + type my_int = int + + assert converter.structure("42", my_int) == 42 + assert converter.unstructure(42, my_int) == 42 + + type my_other_int = int + + # Manual hooks should work. + + converter.register_structure_hook_func( + lambda t: t is my_other_int, lambda v, _: v + 10 + ) + converter.register_unstructure_hook_func( + lambda t: t is my_other_int, lambda v: v - 20 + ) + + assert converter.structure(1, my_other_int) == 11 + assert converter.unstructure(100, my_other_int) == 80 + + +def test_type_aliases_overwrite_base_hooks(converter: BaseConverter): + """Overwriting base hooks should affect type aliases.""" + converter.register_structure_hook(int, lambda v, _: v + 10) + converter.register_unstructure_hook(int, lambda v: v - 20) + + type my_int = int + + assert converter.structure(1, my_int) == 11 + assert converter.unstructure(100, my_int) == 80