diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d348ab6b..64a03019 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,13 +19,15 @@ jobs: steps: - uses: "actions/checkout@v4" + with: + persist-credentials: false - uses: "pdm-project/setup-pdm@v4" with: python-version: "${{ matrix.python-version }}" allow-python-prereleases: true cache: true - version: "2.19.2" + version: "2.21.0" - name: "Run Tox" run: | @@ -48,6 +50,8 @@ jobs: steps: - uses: "actions/checkout@v4" + with: + persist-credentials: false - uses: "actions/setup-python@v5" with: @@ -75,7 +79,7 @@ jobs: echo "total=$TOTAL" >> $GITHUB_ENV # Report again and fail if under the threshold. - python -Im coverage report --fail-under=99 + python -Im coverage report --fail-under=100 - name: "Upload HTML report." uses: "actions/upload-artifact@v4" @@ -104,10 +108,12 @@ jobs: steps: - uses: "actions/checkout@v4" + with: + persist-credentials: false - uses: "pdm-project/setup-pdm@v4" with: python-version: "3.12" - version: "2.19.2" + version: "2.21.0" - name: "Install check-wheel-content and twine" run: "python -m pip install twine check-wheel-contents" diff --git a/.github/workflows/pypi-package.yml b/.github/workflows/pypi-package.yml index 63c6b784..9395b82a 100644 --- a/.github/workflows/pypi-package.yml +++ b/.github/workflows/pypi-package.yml @@ -10,10 +10,6 @@ on: - published workflow_dispatch: -permissions: - contents: read - id-token: write - jobs: build-package: name: Build & verify package @@ -23,8 +19,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - - uses: hynek/build-and-inspect-python-package@v1 + - uses: hynek/build-and-inspect-python-package@v2 # Upload to Test PyPI on every commit on main. release-test-pypi: @@ -33,10 +30,12 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: build-package + permissions: + id-token: write steps: - name: Download packages built by build-and-inspect-python-package - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: Packages path: dist @@ -53,10 +52,12 @@ jobs: if: github.event.action == 'published' runs-on: ubuntu-latest needs: build-package + permissions: + id-token: write steps: - name: Download packages built by build-and-inspect-python-package - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: Packages path: dist diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000..55b3dca1 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,39 @@ +name: Zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["*"] + +permissions: + contents: read + +jobs: + zizmor: + name: Zizmor latest via uv + runs-on: ubuntu-latest + permissions: + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - name: Run zizmor 🌈 + run: uvx zizmor --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + # Path to SARIF file relative to the root of the repository + sarif_file: results.sarif + # Optional category for the results + # Used to differentiate multiple results for one commit + category: zizmor diff --git a/HISTORY.md b/HISTORY.md index 5f20c100..8b3239f2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -10,7 +10,7 @@ The third number is for emergencies when we need to start branches for older rel Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md). -## 24.2.0 (UNRELEASED) +## 25.1.0 (UNRELEASED) - **Potentially breaking**: The converters raise {class}`StructureHandlerNotFoundError` more eagerly (on hook creation, instead of on hook use). This helps surfacing problems with missing hooks sooner. @@ -20,8 +20,10 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#577](https://github.com/python-attrs/cattrs/pull/577)) - Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`. - Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and - {func}`cattrs.cols.is_defaultdict`{func} and `cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`. + {func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`. ([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588)) +- Generic PEP 695 type aliases are now supported. + ([#611](https://github.com/python-attrs/cattrs/issues/611) [#618](https://github.com/python-attrs/cattrs/pull/618)) - Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums, leaving them to the underlying libraries to handle with greater efficiency. ([#598](https://github.com/python-attrs/cattrs/pull/598)) @@ -29,7 +31,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#598](https://github.com/python-attrs/cattrs/pull/598)) - Preconf converters now handle dictionaries with literal keys properly. ([#599](https://github.com/python-attrs/cattrs/pull/599)) -- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`. +- Structuring TypedDicts from invalid inputs now properly raises a {class}`ClassValidationError`. + ([#615](https://github.com/python-attrs/cattrs/issues/615) [#616](https://github.com/python-attrs/cattrs/pull/616)) +- Replace `cattrs.gen.MappingStructureFn` with {class}`cattrs.SimpleStructureHook`. - Python 3.13 is now supported. ([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547)) - Python 3.8 is no longer supported, as it is end-of-life. Use previous versions on this Python version. diff --git a/Makefile b/Makefile index bc2a1525..545b70d9 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ clean-test: ## remove test and coverage artifacts rm -fr htmlcov/ lint: ## check style with ruff and black - pdm run ruff src/ tests bench + pdm run ruff check src/ tests bench pdm run black --check src tests docs/conf.py test: ## run tests quickly with the default Python diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 4b3097d9..23fba82a 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -333,7 +333,12 @@ Generic TypedDicts work on Python 3.11 and later, since that is the first Python [`typing.Required` and `typing.NotRequired`](https://peps.python.org/pep-0655/) are supported. -[Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. +:::{caution} +If `from __future__ import annotations` is used or if annotations are given as strings, `Required` and `NotRequired` are ignored by cattrs. +See [note in the Python documentation](https://docs.python.org/3/library/typing.html#typing.TypedDict.__optional_keys__). +::: + +[Similar to _attrs_ classes](customizing.md#using-cattrsgen-hook-factories), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. ```{doctest} >>> from typing import TypedDict diff --git a/pdm.lock b/pdm.lock index 2250cdaa..8d170ae5 100644 --- a/pdm.lock +++ b/pdm.lock @@ -413,54 +413,51 @@ files = [ [[package]] name = "immutables" -version = "0.20" +version = "0.21" requires_python = ">=3.8.0" summary = "Immutable Collections" -dependencies = [ - "typing-extensions>=3.7.4.3; python_version < \"3.8\"", -] -files = [ - {file = "immutables-0.20-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dea0ae4d7f31b145c18c16badeebc2f039d09411be4a8febb86e1244cf7f1ce0"}, - {file = "immutables-0.20-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2dd0dcef2f8d4523d34dbe1d2b7804b3d2a51fddbd104aad13f506a838a2ea15"}, - {file = "immutables-0.20-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:393dde58ffd6b4c089ffdf4cef5fe73dad37ce4681acffade5f5d5935ec23c93"}, - {file = "immutables-0.20-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1214b5a175df783662b7de94b4a82db55cc0ee206dd072fa9e279fb8895d8df"}, - {file = "immutables-0.20-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2761e3dc2a6406943ce77b3505e9b3c1187846de65d7247548dc7edaa202fcba"}, - {file = "immutables-0.20-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2bcea81e7516bd823b4ed16f4f794531097888675be13e833b1cc946370d5237"}, - {file = "immutables-0.20-cp310-cp310-win32.whl", hash = "sha256:d828e7580f1fa203ddeab0b5e91f44bf95706e7f283ca9fbbcf0ae08f63d3084"}, - {file = "immutables-0.20-cp310-cp310-win_amd64.whl", hash = "sha256:380e2957ba3d63422b2f3fbbff0547c7bbe6479d611d3635c6411005a4264525"}, - {file = "immutables-0.20-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:532be32c7a25dae6cade28825c76d3004cf4d166a0bfacf04bda16056d59ba26"}, - {file = "immutables-0.20-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5302ce9c7827f8300f3dc34a695abb71e4a32bab09e65e5ad6e454785383347f"}, - {file = "immutables-0.20-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51aec54b571ae466113509d4dc79a2808dc2ae9263b71fd6b37778cb49eb292"}, - {file = "immutables-0.20-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f56aea56e597ecf6631f24a4e26007b6a5f4fe30278b96eb90bc1f60506164"}, - {file = "immutables-0.20-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:085ac48ee3eef7baf070f181cae574489bbf65930a83ec5bbd65c9940d625db3"}, - {file = "immutables-0.20-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f063f53b5c0e8f541ae381f1d828f3d05bbed766a2d6c817f9218b8b37a4cb66"}, - {file = "immutables-0.20-cp311-cp311-win32.whl", hash = "sha256:b0436cc831b47e26bef637bcf143cf0273e49946cfb7c28c44486d70513a3080"}, - {file = "immutables-0.20-cp311-cp311-win_amd64.whl", hash = "sha256:5bb32aee1ea16fbb90f58f8bd96016bca87aba0a8e574e5fa218d0d83b142851"}, - {file = "immutables-0.20-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ba726b7a3a696b9d4b122fa2c956bc68e866f3df1b92765060c88c64410ff82"}, - {file = "immutables-0.20-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5a88adf1dcc9d8ab07dba5e74deefcd5b5e38bc677815cbf9365dc43b69f1f08"}, - {file = "immutables-0.20-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1009a4e00e2e69a9b40c2f1272795f5a06ad72c9bf4638594d518e9cbd7a721a"}, - {file = "immutables-0.20-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96899994842c37cf4b9d6d2bedf685aae7810bd73f1538f8cba5426e2d65cb85"}, - {file = "immutables-0.20-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a606410b2ccb6ae339c3f26cccc9a92bcb16dc06f935d51edfd8ca68cf687e50"}, - {file = "immutables-0.20-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8e82754f72823085643a2c0e6a4c489b806613e94af205825fa81df2ba147a0"}, - {file = "immutables-0.20-cp312-cp312-win32.whl", hash = "sha256:525fb361bd7edc8a891633928d549713af8090c79c25af5cc06eb90b48cb3c64"}, - {file = "immutables-0.20-cp312-cp312-win_amd64.whl", hash = "sha256:a82afc3945e9ceb9bcd416dc4ed9b72f92760c42787e26de50610a8b81d48120"}, - {file = "immutables-0.20-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f17f25f21e82a1c349a61191cfb13e442a348b880b74cb01b00e0d1e848b63f4"}, - {file = "immutables-0.20-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:65954eb861c61af48debb1507518d45ae7d594b4fba7282785a70b48c5f51f9b"}, - {file = "immutables-0.20-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f8a7a22939278127b7a206d05679b268b9cf665437125625348e902617cbad"}, - {file = "immutables-0.20-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac86f4372f4cfaa00206c12472fd3a78753092279e0552b7e1880944d71b04fe"}, - {file = "immutables-0.20-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e771198edc11a9e02ffa693911b3918c6cde0b64ad2e6672b076dbe005557ad8"}, - {file = "immutables-0.20-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fc739fc07cff5df2e4f31addbd48660b5ac0da56e9f719f8bb45da8ddd632c63"}, - {file = "immutables-0.20-cp38-cp38-win32.whl", hash = "sha256:c086ccb44d9d3824b9bf816365d10b1b82837efc7119f8bab56bd7a27ed805a9"}, - {file = "immutables-0.20-cp38-cp38-win_amd64.whl", hash = "sha256:9cd2ee9c10bf00be3c94eb51854bc0b761326bd0a7ea0dad4272a3f182269ae6"}, - {file = "immutables-0.20-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4f78cb748261f852953620ed991de74972446fd484ec69377a41e2f1a1beb75"}, - {file = "immutables-0.20-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6449186ea91b7c17ec8e7bd9bf059858298b1db5c053f5d27de8eba077578ce"}, - {file = "immutables-0.20-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85dd9765b068f7beb297553fddfcf7f904bd58a184c520830a106a58f0c9bfb4"}, - {file = "immutables-0.20-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f349a7e0327b92dcefb863e49ace086f2f26e6689a4e022c98720c6e9696e763"}, - {file = "immutables-0.20-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e3a5462f6d3549bbf7d02ce929fb0cb6df9539445f0589105de4e8b99b906e69"}, - {file = "immutables-0.20-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cc51a01a64a6d2cd7db210a49ad010c2ac2e9e026745f23fd31e0784096dcfff"}, - {file = "immutables-0.20-cp39-cp39-win32.whl", hash = "sha256:83794712f0507416f2818edc63f84305358b8656a93e5b9e2ab056d9803c7507"}, - {file = "immutables-0.20-cp39-cp39-win_amd64.whl", hash = "sha256:2837b1078abc66d9f009bee9085cf62515d5516af9a5c9ea2751847e16efd236"}, - {file = "immutables-0.20.tar.gz", hash = "sha256:1d2f83e6a6a8455466cd97b9a90e2b4f7864648616dfa6b19d18f49badac3876"}, +files = [ + {file = "immutables-0.21-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:14cb09d4f4577ad9ab8770a340dc2158e0a5ab5775cb34c75960167a31104212"}, + {file = "immutables-0.21-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:22ba593f95044ac60d2af463f3dc86cd0e223f8c51df85dff65d663d93e19f51"}, + {file = "immutables-0.21-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25afc81a7bcf26c8364f85e52a14e0095344343e79493c73b0e9a765310a0bed"}, + {file = "immutables-0.21-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac6e2868567289f88c6810f296940c328a1d38c9abc841eed04963102a27d12"}, + {file = "immutables-0.21-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ba8bca21a1d034f4577ede1e9553a681dd01199c06b563f1a8316f2623b64985"}, + {file = "immutables-0.21-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:39337bfb42f83dd787a81e2d00e90efa17c4a39a9cf1210b8a50dafe32438aae"}, + {file = "immutables-0.21-cp310-cp310-win32.whl", hash = "sha256:b24aa98f6cdae4ba15baf3aa00e84223bafcd0d3fd7f0443474527ec951845e1"}, + {file = "immutables-0.21-cp310-cp310-win_amd64.whl", hash = "sha256:715f8e5f8e1c35f036f9ac62eaf8b672eec1cdc2b4f9b73864cc64eccc76661c"}, + {file = "immutables-0.21-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5d780c38067047911a2e06a86ba063ba0055618ab5573c8198ef3f368e321303"}, + {file = "immutables-0.21-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9aab9d0f0016f6e0bfe7e4a4cb831ef20063da6468b1bbc71d06ef285781ee9e"}, + {file = "immutables-0.21-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ff83390b05d3372acb9a0c928f6cc20c78e74ca20ed88eb941f84a63b65e444"}, + {file = "immutables-0.21-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01497713e71509c4481ffccdbe3a47b94969345f4e92f814d6626f7c0a4c304"}, + {file = "immutables-0.21-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc7844c9fbb5bece5bfdf2bf8ea74d308f42f40b0665fd25c58abf56d7db024a"}, + {file = "immutables-0.21-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:984106fa4345efd9f96de22e9949fc97bac8598bdebee03c20b2497a88bff3b7"}, + {file = "immutables-0.21-cp311-cp311-win32.whl", hash = "sha256:1bdb5200518518601377e4877d5034e7c535e9ea8a9d601ed8b0eedef0c7becd"}, + {file = "immutables-0.21-cp311-cp311-win_amd64.whl", hash = "sha256:dd00c34f431c54c95e7b84bfdbdeacb4f039a6a24eb0c1f7aa4b168bb9a6ad0a"}, + {file = "immutables-0.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1ed262094b755903122c3c3a83ad0e0d5c3ab7887cda12b2fe878769d1ee0d"}, + {file = "immutables-0.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce604f81d9d8f26e60b52ebcb56bb5c0462c8ea50fb17868487d15f048a2f13e"}, + {file = "immutables-0.21-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b48b116aaca4500398058b5a87814857a60c4cb09417fecc12d7da0f5639b73d"}, + {file = "immutables-0.21-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7c0c74b285cc0e555ec0e97acbdc6f1862fcd16b99abd612df3243732e741"}, + {file = "immutables-0.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e44346e2221a5a676c880ca8e0e6429fa24d1a4ae562573f5c04d7f2e759b030"}, + {file = "immutables-0.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b10139b529a460e53fe8be699ebd848c54c8a33ebe67763bcfcc809a475a26f"}, + {file = "immutables-0.21-cp312-cp312-win32.whl", hash = "sha256:fc512d808662614feb17d2d92e98f611d69669a98c7af15910acf1dc72737038"}, + {file = "immutables-0.21-cp312-cp312-win_amd64.whl", hash = "sha256:461dcb0f58a131045155e52a2c43de6ec2fe5ba19bdced6858a3abb63cee5111"}, + {file = "immutables-0.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:79674b51aa8dd983f9ac55f7f67b433b1df84a6b4f28ab860588389a5659485b"}, + {file = "immutables-0.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93c8350f8f7d0d9693f708229d9d0578e6f3b785ce6da4bced1da97137aacfad"}, + {file = "immutables-0.21-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:583d2a63e444ce1538cc2bda56ae1f4a1a11473dbc0377c82b516bc7eec3b81e"}, + {file = "immutables-0.21-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b274a52da9b106db55eceb93fc1aea858c4e6f4740189e3548e38613eafc2021"}, + {file = "immutables-0.21-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:338bede057250b33716a3e4892e15df0bf5a5ddbf1d67ead996b3e680b49ef9e"}, + {file = "immutables-0.21-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8781c89583b68f604cf30f0978b722165824c3075888639fde771bf1a3e12dc0"}, + {file = "immutables-0.21-cp313-cp313-win32.whl", hash = "sha256:e97ea83befad873712f283c0cccd630f70cba753e207b4868af28d5b85e9dc54"}, + {file = "immutables-0.21-cp313-cp313-win_amd64.whl", hash = "sha256:cfcb23bd898f5a4ef88692b42c51f52ca7373a35ba4dcc215060a668639eb5da"}, + {file = "immutables-0.21-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e2aadf3bdd90daa0e8cb9c3cde4070e1021036e3b57f571a007ce24f323e47a9"}, + {file = "immutables-0.21-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5f8f507731d4d15e0c579aa77d8482471f988dc0f451e4bf3853ec36ccd42627"}, + {file = "immutables-0.21-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb9a378a4480381d7d3d63b0d201cf610eae0bf70e26a9306e3e631c9bd64010"}, + {file = "immutables-0.21-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7b5920bbfcaf038894c8ce4ed2eff0b31c3559810a61806db751be8ab4d703"}, + {file = "immutables-0.21-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8b90702d1fe313e8273ae7abb46fc0f0a87b47c1c9a83aed9a161301146e655c"}, + {file = "immutables-0.21-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:71cbbc6fbe7e7321648047ff9273f4605f8bd5ce456841a65ef151080e9d3481"}, + {file = "immutables-0.21-cp39-cp39-win32.whl", hash = "sha256:c44f286c47dc0d4d7b5bf19fbe975e6d57c56d2878cea413e1ec7a4bfffb2727"}, + {file = "immutables-0.21-cp39-cp39-win_amd64.whl", hash = "sha256:cf15314c39484b8947a4e20c3526021272510592fb2807b5136a2fcd6ab0151b"}, + {file = "immutables-0.21.tar.gz", hash = "sha256:b55ffaf0449790242feb4c56ab799ea7af92801a0a43f9e2f4f8af2ab24dfc4a"}, ] [[package]] @@ -1168,27 +1165,28 @@ files = [ [[package]] name = "ruff" -version = "0.1.13" +version = "0.9.1" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e3fd36e0d48aeac672aa850045e784673449ce619afc12823ea7868fcc41d8ba"}, - {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9fb6b3b86450d4ec6a6732f9f60c4406061b6851c4b29f944f8c9d91c3611c7a"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b13ba5d7156daaf3fd08b6b993360a96060500aca7e307d95ecbc5bb47a69296"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ebb40442f7b531e136d334ef0851412410061e65d61ca8ce90d894a094feb22"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226b517f42d59a543d6383cfe03cccf0091e3e0ed1b856c6824be03d2a75d3b6"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f0312ba1061e9b8c724e9a702d3c8621e3c6e6c2c9bd862550ab2951ac75c16"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f59bcf5217c661254bd6bc42d65a6fd1a8b80c48763cb5c2293295babd945dd"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6894b00495e00c27b6ba61af1fc666f17de6140345e5ef27dd6e08fb987259d"}, - {file = "ruff-0.1.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1600942485c6e66119da294c6294856b5c86fd6df591ce293e4a4cc8e72989"}, - {file = "ruff-0.1.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ee3febce7863e231a467f90e681d3d89210b900d49ce88723ce052c8761be8c7"}, - {file = "ruff-0.1.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dcaab50e278ff497ee4d1fe69b29ca0a9a47cd954bb17963628fa417933c6eb1"}, - {file = "ruff-0.1.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f57de973de4edef3ad3044d6a50c02ad9fc2dff0d88587f25f1a48e3f72edf5e"}, - {file = "ruff-0.1.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a36fa90eb12208272a858475ec43ac811ac37e91ef868759770b71bdabe27b6"}, - {file = "ruff-0.1.13-py3-none-win32.whl", hash = "sha256:a623349a505ff768dad6bd57087e2461be8db58305ebd5577bd0e98631f9ae69"}, - {file = "ruff-0.1.13-py3-none-win_amd64.whl", hash = "sha256:f988746e3c3982bea7f824c8fa318ce7f538c4dfefec99cd09c8770bd33e6539"}, - {file = "ruff-0.1.13-py3-none-win_arm64.whl", hash = "sha256:6bbbc3042075871ec17f28864808540a26f0f79a4478c357d3e3d2284e832998"}, - {file = "ruff-0.1.13.tar.gz", hash = "sha256:e261f1baed6291f434ffb1d5c6bd8051d1c2a26958072d38dfbec39b3dda7352"}, + {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"}, + {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"}, + {file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"}, + {file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"}, + {file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"}, + {file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"}, + {file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"}, + {file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"}, + {file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"}, + {file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"}, + {file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"}, + {file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 0a562cbb..27ac74b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,8 @@ exclude_also = [ [tool.ruff] src = ["src", "tests"] + +[tool.ruff.lint] select = [ "E", # pycodestyle "W", # pycodestyle @@ -144,7 +146,6 @@ ignore = [ "S101", # assert "S307", # hands off my eval "SIM300", # Yoda rocks in asserts - "PGH001", # No eval lol? "PGH003", # leave my type: ignores alone "B006", # mutable argument defaults "DTZ001", # datetimes in tests diff --git a/src/cattr/__init__.py b/src/cattr/__init__.py index 6c262fe8..50f2a063 100644 --- a/src/cattr/__init__.py +++ b/src/cattr/__init__.py @@ -2,16 +2,16 @@ from .gen import override __all__ = ( - "global_converter", - "unstructure", - "structure", - "structure_attrs_fromtuple", - "structure_attrs_fromdict", - "UnstructureStrategy", "BaseConverter", "Converter", "GenConverter", + "UnstructureStrategy", + "global_converter", "override", + "structure", + "structure_attrs_fromdict", + "structure_attrs_fromtuple", + "unstructure", ) from cattrs import global_converter diff --git a/src/cattr/preconf/json.py b/src/cattr/preconf/json.py index d590bd6d..ac773984 100644 --- a/src/cattr/preconf/json.py +++ b/src/cattr/preconf/json.py @@ -2,4 +2,4 @@ from cattrs.preconf.json import JsonConverter, configure_converter, make_converter -__all__ = ["configure_converter", "JsonConverter", "make_converter"] +__all__ = ["JsonConverter", "configure_converter", "make_converter"] diff --git a/src/cattr/preconf/msgpack.py b/src/cattr/preconf/msgpack.py index 1a579d63..bb90250c 100644 --- a/src/cattr/preconf/msgpack.py +++ b/src/cattr/preconf/msgpack.py @@ -2,4 +2,4 @@ from cattrs.preconf.msgpack import MsgpackConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", "MsgpackConverter"] +__all__ = ["MsgpackConverter", "configure_converter", "make_converter"] diff --git a/src/cattr/preconf/orjson.py b/src/cattr/preconf/orjson.py index 44509901..569ec181 100644 --- a/src/cattr/preconf/orjson.py +++ b/src/cattr/preconf/orjson.py @@ -2,4 +2,4 @@ from cattrs.preconf.orjson import OrjsonConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", "OrjsonConverter"] +__all__ = ["OrjsonConverter", "configure_converter", "make_converter"] diff --git a/src/cattr/preconf/pyyaml.py b/src/cattr/preconf/pyyaml.py index 63d39f18..6bf8b369 100644 --- a/src/cattr/preconf/pyyaml.py +++ b/src/cattr/preconf/pyyaml.py @@ -2,4 +2,4 @@ from cattrs.preconf.pyyaml import PyyamlConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", "PyyamlConverter"] +__all__ = ["PyyamlConverter", "configure_converter", "make_converter"] diff --git a/src/cattr/preconf/tomlkit.py b/src/cattr/preconf/tomlkit.py index 6add7319..7c0e7039 100644 --- a/src/cattr/preconf/tomlkit.py +++ b/src/cattr/preconf/tomlkit.py @@ -2,4 +2,4 @@ from cattrs.preconf.tomlkit import TomlkitConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", "TomlkitConverter"] +__all__ = ["TomlkitConverter", "configure_converter", "make_converter"] diff --git a/src/cattr/preconf/ujson.py b/src/cattr/preconf/ujson.py index ef85c475..2efbbeeb 100644 --- a/src/cattr/preconf/ujson.py +++ b/src/cattr/preconf/ujson.py @@ -2,4 +2,4 @@ from cattrs.preconf.ujson import UjsonConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", "UjsonConverter"] +__all__ = ["UjsonConverter", "configure_converter", "make_converter"] diff --git a/src/cattrs/__init__.py b/src/cattrs/__init__.py index 18ab4aea..2252272c 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -22,24 +22,24 @@ "Converter", "ForbiddenExtraKeysError", "GenConverter", + "IterableValidationError", + "IterableValidationNote", + "SimpleStructureHook", + "StructureHandlerNotFoundError", + "UnstructureStrategy", "get_structure_hook", "get_unstructure_hook", "global_converter", - "IterableValidationError", - "IterableValidationNote", "override", - "register_structure_hook_func", "register_structure_hook", - "register_unstructure_hook_func", + "register_structure_hook_func", "register_unstructure_hook", - "SimpleStructureHook", + "register_unstructure_hook_func", + "structure", "structure_attrs_fromdict", "structure_attrs_fromtuple", - "structure", - "StructureHandlerNotFoundError", "transform_error", "unstructure", - "UnstructureStrategy", ] #: The global converter. Prefer creating your own if customizations are required. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 85b41a95..d8f74482 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -25,7 +25,6 @@ Optional, Protocol, Tuple, - TypedDict, Union, _AnnotatedAlias, _GenericAlias, @@ -49,16 +48,13 @@ __all__ = [ "ANIES", - "adapted_fields", - "fields_dict", "ExceptionGroup", "ExtensionsTypedDict", - "get_type_alias_base", + "TypeAlias", + "adapted_fields", + "fields_dict", "has", - "is_type_alias", "is_typeddict", - "TypeAlias", - "TypedDict", ] try: @@ -112,20 +108,6 @@ def is_typeddict(cls: Any): 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? - - Works only on 3.12+. - """ - return type.__value__ - - def has(cls): return hasattr(cls, "__attrs_attrs__") or hasattr(cls, "__dataclass_fields__") @@ -273,14 +255,6 @@ def is_tuple(type): ) -if sys.version_info >= (3, 12): - from typing import TypeAliasType - - def is_type_alias(type: Any) -> bool: - """Is this a PEP 695 type alias?""" - return isinstance(type, TypeAliasType) - - if sys.version_info >= (3, 10): def is_union_type(obj): @@ -307,10 +281,8 @@ def get_newtype_base(typ: Any) -> Optional[type]: from typing_extensions import NotRequired, Required def is_union_type(obj): - return ( - obj is Union - or isinstance(obj, _UnionGenericAlias) - and obj.__origin__ is Union + return obj is Union or ( + isinstance(obj, _UnionGenericAlias) and obj.__origin__ is Union ) def get_newtype_base(typ: Any) -> Optional[type]: @@ -356,10 +328,8 @@ def is_sequence(type: Any) -> bool: or ( type.__class__ is _GenericAlias and ( - (origin is not tuple) - and is_subclass(origin, TypingSequence) - or origin is tuple - and type.__args__[1] is ... + ((origin is not tuple) and is_subclass(origin, TypingSequence)) + or (origin is tuple and type.__args__[1] is ...) ) ) or (origin in (list, deque, AbcMutableSequence, AbcSequence)) diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index fc2ac986..701bb53b 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -46,22 +46,22 @@ from .converters import BaseConverter __all__ = [ + "defaultdict_structure_factory", "is_any_set", "is_defaultdict", "is_frozenset", - "is_namedtuple", "is_mapping", - "is_set", + "is_namedtuple", "is_sequence", - "defaultdict_structure_factory", + "is_set", "iterable_unstructure_factory", "list_structure_factory", - "namedtuple_structure_factory", - "namedtuple_unstructure_factory", - "namedtuple_dict_structure_factory", - "namedtuple_dict_unstructure_factory", "mapping_structure_factory", "mapping_unstructure_factory", + "namedtuple_dict_structure_factory", + "namedtuple_dict_unstructure_factory", + "namedtuple_structure_factory", + "namedtuple_unstructure_factory", ] diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 3644786c..4f65bb47 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -30,7 +30,6 @@ get_final_base, get_newtype_base, get_origin, - get_type_alias_base, has, has_with_generic, is_annotated, @@ -48,7 +47,6 @@ is_protocol, is_sequence, is_tuple, - is_type_alias, is_typeddict, is_union_type, signature, @@ -92,9 +90,14 @@ from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn from .literals import is_literal_containing_enums +from .typealiases import ( + get_type_alias_base, + is_type_alias, + type_alias_structure_factory, +) from .types import SimpleStructureHook -__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"] +__all__ = ["BaseConverter", "Converter", "GenConverter", "UnstructureStrategy"] T = TypeVar("T") V = TypeVar("V") @@ -162,16 +165,16 @@ class BaseConverter: """Converts between structured and unstructured data.""" __slots__ = ( - "_unstructure_func", - "_unstructure_attrs", - "_structure_attrs", "_dict_factory", - "_union_struct_registry", - "_structure_func", "_prefer_attrib_converters", - "detailed_validation", "_struct_copy_skip", + "_structure_attrs", + "_structure_func", + "_union_struct_registry", "_unstruct_copy_skip", + "_unstructure_attrs", + "_unstructure_func", + "detailed_validation", ) def __init__( @@ -259,7 +262,7 @@ def __init__( ), (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), + (is_type_alias, type_alias_structure_factory, "extended"), ( lambda t: get_final_base(t) is not None, self._structure_final_factory, @@ -699,14 +702,6 @@ def _structure_newtype(self, val: UnstructuredValue, type) -> StructuredValue: base = get_newtype_base(type) return self.get_structure_hook(base)(val, base) - def _find_type_alias_structure_hook(self, type: Any) -> StructureHook: - base = get_type_alias_base(type) - res = self.get_structure_hook(base) - if res == self._structure_call: - # we need to replace the type arg of `structure_call` - return lambda v, _, __base=base: __base(v) - return lambda v, _, __base=base: res(v, __base) - def _structure_final_factory(self, type): base = get_final_base(type) res = self.get_structure_hook(base) @@ -885,7 +880,7 @@ def _structure_optional(self, obj, union): # We can't actually have a Union of a Union, so this is safe. return self._structure_func.dispatch(other)(obj, other) - def _structure_tuple(self, obj: Any, tup: type[T]) -> T: + def _structure_tuple(self, obj: Iterable, tup: type[T]) -> T: """Deal with structuring into a tuple.""" tup_params = None if tup in (Tuple, tuple) else tup.__args__ has_ellipsis = tup_params and tup_params[-1] is Ellipsis @@ -893,7 +888,7 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T: # Just a Tuple. (No generic information.) return tuple(obj) if has_ellipsis: - # We're dealing with a homogenous tuple, Tuple[int, ...] + # We're dealing with a homogenous tuple, tuple[int, ...] tup_type = tup_params[0] conv = self._structure_func.dispatch(tup_type) if self.detailed_validation: @@ -920,13 +915,6 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T: # We're dealing with a heterogenous tuple. exp_len = len(tup_params) - try: - len_obj = len(obj) - except TypeError: - pass # most likely an unsized iterator, eg generator - else: - if len_obj > exp_len: - exp_len = len_obj if self.detailed_validation: errors = [] res = [] @@ -940,8 +928,8 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T: ) exc.__notes__ = [*getattr(exc, "__notes__", []), msg] errors.append(exc) - if len(res) < exp_len: - problem = "Not enough" if len(res) < len(tup_params) else "Too many" + if len(obj) != exp_len: + problem = "Not enough" if len(res) < exp_len else "Too many" exc = ValueError(f"{problem} values in {obj!r} to structure as {tup!r}") msg = f"Structuring {tup}" exc.__notes__ = [*getattr(exc, "__notes__", []), msg] @@ -950,13 +938,12 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T: raise IterableValidationError(f"While structuring {tup!r}", errors, tup) return tuple(res) - res = tuple( + if len(obj) != exp_len: + problem = "Not enough" if len(obj) < len(tup_params) else "Too many" + raise ValueError(f"{problem} values in {obj!r} to structure as {tup!r}") + return tuple( [self._structure_func.dispatch(t)(e, t) for t, e in zip(tup_params, obj)] ) - if len(res) < exp_len: - problem = "Not enough" if len(res) < len(tup_params) else "Too many" - raise ValueError(f"{problem} values in {obj!r} to structure as {tup!r}") - return res def _get_dis_func( self, @@ -971,11 +958,9 @@ def _get_dis_func( # logic. union_types = tuple(e for e in union_types if e is not NoneType) - # TODO: technically both disambiguators could support TypedDicts and - # dataclasses... if not all(has(get_origin(e) or e) for e in union_types): raise StructureHandlerNotFoundError( - "Only unions of attrs classes supported " + "Only unions of attrs classes and dataclasses supported " "currently. Register a structure hook manually.", type_=union, ) @@ -1035,10 +1020,10 @@ class Converter(BaseConverter): """A converter which generates specialized un/structuring functions.""" __slots__ = ( - "omit_if_default", + "_unstruct_collection_overrides", "forbid_extra_keys", + "omit_if_default", "type_overrides", - "_unstruct_collection_overrides", ) def __init__( diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index 83e8c3f1..6fc5d9da 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -26,11 +26,11 @@ if TYPE_CHECKING: from .converters import BaseConverter -__all__ = ["is_supported_union", "create_default_dis_func"] +__all__ = ["create_default_dis_func", "is_supported_union"] def is_supported_union(typ: Any) -> bool: - """Whether the type is a union of attrs classes.""" + """Whether the type is a union of attrs classes or dataclasses.""" return is_union_type(typ) and all( e is NoneType or has(get_origin(e) or e) for e in typ.__args__ ) diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 2da3145c..4f9a7377 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -1,5 +1,8 @@ +from collections.abc import Sequence from typing import Any, Optional, Union +from typing_extensions import Self + from cattrs._compat import ExceptionGroup @@ -17,13 +20,13 @@ def __init__(self, message: str, type_: type) -> None: class BaseValidationError(ExceptionGroup): cl: type - def __new__(cls, message, excs, cl: type): + def __new__(cls, message: str, excs: Sequence[Exception], cl: type): obj = super().__new__(cls, message, excs) obj.cl = cl return obj - def derive(self, excs): - return ClassValidationError(self.message, excs, self.cl) + def derive(self, excs: Sequence[Exception]) -> Self: + return self.__class__(self.message, excs, self.cl) class IterableValidationNote(str): diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index c0df0191..a81e2561 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -39,14 +39,14 @@ from ..converters import BaseConverter __all__ = [ - "make_dict_unstructure_fn", "make_dict_structure_fn", - "make_iterable_unstructure_fn", + "make_dict_structure_fn_from_attrs", + "make_dict_unstructure_fn", + "make_dict_unstructure_fn_from_attrs", "make_hetero_tuple_unstructure_fn", - "make_mapping_unstructure_fn", + "make_iterable_unstructure_fn", "make_mapping_structure_fn", - "make_dict_unstructure_fn_from_attrs", - "make_dict_structure_fn_from_attrs", + "make_mapping_unstructure_fn", ] @@ -245,10 +245,6 @@ def make_dict_unstructure_fn( if is_generic(cl): mapping = generate_mapping(cl, mapping) - for base in getattr(origin, "__orig_bases__", ()): - if is_generic(base) and not str(base).startswith("typing.Generic"): - mapping = generate_mapping(base, mapping) - break if origin is not None: cl = origin @@ -873,7 +869,7 @@ def mapping_unstructure_factory( lines = [f"def {fn_name}(mapping):"] - if unstructure_to is dict or unstructure_to is None and origin is dict: + if unstructure_to is dict or (unstructure_to is None and origin is dict): if kh is None and val_handler is None: # Simplest path. return dict diff --git a/src/cattrs/gen/_generics.py b/src/cattrs/gen/_generics.py index 069c48c8..63d2fb91 100644 --- a/src/cattrs/gen/_generics.py +++ b/src/cattrs/gen/_generics.py @@ -36,14 +36,7 @@ def generate_mapping(cl: type, old_mapping: dict[str, type] = {}) -> dict[str, t origin = get_origin(cl) if origin is not None: - # To handle the cases where classes in the typing module are using - # the GenericAlias structure but aren't a Generic and hence - # end up in this function but do not have an `__parameters__` - # attribute. These classes are interface types, for example - # `typing.Hashable`. - parameters = getattr(get_origin(cl), "__parameters__", None) - if parameters is None: - return dict(old_mapping) + parameters = origin.__parameters__ for p, t in zip(parameters, get_args(cl)): if isinstance(t, TypeVar): diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index d5dcdab6..dcb58641 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -2,7 +2,8 @@ import re import sys -from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, TypeVar from attrs import NOTHING, Attribute from typing_extensions import _TypedDictMeta @@ -20,7 +21,6 @@ def get_annots(cl) -> dict[str, Any]: from .._compat import ( - TypedDict, get_full_type_hints, get_notrequired_base, get_origin, @@ -45,7 +45,7 @@ def get_annots(cl) -> dict[str, Any]: if TYPE_CHECKING: from ..converters import BaseConverter -__all__ = ["make_dict_unstructure_fn", "make_dict_structure_fn"] +__all__ = ["make_dict_structure_fn", "make_dict_unstructure_fn"] T = TypeVar("T", bound=TypedDict) @@ -307,6 +307,16 @@ def make_dict_structure_fn( globs["__c_a"] = allowed_fields globs["__c_feke"] = ForbiddenExtraKeysError + if _cattrs_detailed_validation: + # When running under detailed validation, be extra careful about the + # input type so that the correct error is raised if the input isn't a dict. + internal_arg_parts["__c_mapping"] = Mapping + lines.append(" if not isinstance(o, __c_mapping):") + te = "TypeError(f'expected a mapping, not {o.__class__.__name__}')" + lines.append( + f" raise __c_cve('While structuring ' + {cl.__name__!r}, [{te}], __cl)" + ) + lines.append(" res = o.copy()") if _cattrs_detailed_validation: diff --git a/src/cattrs/preconf/bson.py b/src/cattrs/preconf/bson.py index 7d398b4a..49574893 100644 --- a/src/cattrs/preconf/bson.py +++ b/src/cattrs/preconf/bson.py @@ -61,7 +61,7 @@ def configure_converter(converter: BaseConverter): * a deserialization hook is registered for bson.ObjectId by default * string and int enums are passed through when unstructuring - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ diff --git a/src/cattrs/preconf/json.py b/src/cattrs/preconf/json.py index b6c0ecc2..199c574d 100644 --- a/src/cattrs/preconf/json.py +++ b/src/cattrs/preconf/json.py @@ -13,6 +13,8 @@ from ..strategies import configure_union_passthrough from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +__all__ = ["JsonConverter", "configure_converter", "make_converter"] + T = TypeVar("T") @@ -24,7 +26,7 @@ def loads(self, data: Union[bytes, str], cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) -def configure_converter(converter: BaseConverter): +def configure_converter(converter: BaseConverter) -> None: """ Configure the converter for use with the stdlib json module. @@ -36,7 +38,7 @@ def configure_converter(converter: BaseConverter): * union passthrough is configured for unions of strings, bools, ints, floats and None - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ converter.register_unstructure_hook( diff --git a/src/cattrs/preconf/msgpack.py b/src/cattrs/preconf/msgpack.py index 4e1bddd5..92876418 100644 --- a/src/cattrs/preconf/msgpack.py +++ b/src/cattrs/preconf/msgpack.py @@ -12,6 +12,8 @@ from ..strategies import configure_union_passthrough from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +__all__ = ["MsgpackConverter", "configure_converter", "make_converter"] + T = TypeVar("T") @@ -23,7 +25,7 @@ def loads(self, data: bytes, cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) -def configure_converter(converter: BaseConverter): +def configure_converter(converter: BaseConverter) -> None: """ Configure the converter for use with the msgpack library. @@ -31,7 +33,7 @@ def configure_converter(converter: BaseConverter): * sets are serialized as lists * string and int enums are passed through when unstructuring - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ converter.register_unstructure_hook(datetime, lambda v: v.timestamp()) diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 62673c27..3fa1a3b6 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -75,7 +75,7 @@ def configure_converter(converter: Converter) -> None: * union passthrough configured for str, bool, int, float and None * bare, string and int enums are passed through when unstructuring - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ configure_passthroughs(converter) diff --git a/src/cattrs/preconf/orjson.py b/src/cattrs/preconf/orjson.py index 6e0b6b80..0726ef04 100644 --- a/src/cattrs/preconf/orjson.py +++ b/src/cattrs/preconf/orjson.py @@ -11,12 +11,14 @@ from .._compat import is_subclass from ..cols import is_mapping, is_namedtuple, namedtuple_unstructure_factory -from ..converters import BaseConverter, Converter +from ..converters import Converter from ..fns import identity from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +__all__ = ["OrjsonConverter", "configure_converter", "make_converter"] + T = TypeVar("T") @@ -28,7 +30,7 @@ def loads(self, data: Union[bytes, bytearray, memoryview, str], cl: type[T]) -> return self.structure(loads(data), cl) -def configure_converter(converter: BaseConverter): +def configure_converter(converter: Converter) -> None: """ Configure the converter for use with the orjson library. @@ -40,9 +42,9 @@ def configure_converter(converter: BaseConverter): * mapping keys are coerced into strings when unstructuring * bare, string and int enums are passed through when unstructuring - .. versionchanged: 24.1.0 + .. versionchanged:: 24.1.0 Add support for typed namedtuples. - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ converter.register_unstructure_hook( @@ -53,7 +55,7 @@ def configure_converter(converter: BaseConverter): converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v)) converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v)) - def gen_unstructure_mapping(cl: Any, unstructure_to=None): + def unstructure_mapping_factory(cl: Any, unstructure_to=None): key_handler = str args = getattr(cl, "__args__", None) if args: @@ -77,7 +79,7 @@ def key_handler(v): converter._unstructure_func.register_func_list( [ - (is_mapping, gen_unstructure_mapping, True), + (is_mapping, unstructure_mapping_factory, True), ( is_namedtuple, partial(namedtuple_unstructure_factory, unstructure_to=tuple), diff --git a/src/cattrs/preconf/pyyaml.py b/src/cattrs/preconf/pyyaml.py index 9c0ca99b..b1b88540 100644 --- a/src/cattrs/preconf/pyyaml.py +++ b/src/cattrs/preconf/pyyaml.py @@ -12,6 +12,8 @@ from ..strategies import configure_union_passthrough from . import validate_datetime, wrap +__all__ = ["PyyamlConverter", "configure_converter", "make_converter"] + T = TypeVar("T") @@ -29,7 +31,7 @@ def loads(self, data: str, cl: type[T]) -> T: return self.structure(safe_load(data), cl) -def configure_converter(converter: BaseConverter): +def configure_converter(converter: BaseConverter) -> None: """ Configure the converter for use with the pyyaml library. @@ -38,7 +40,7 @@ def configure_converter(converter: BaseConverter): * datetimes and dates are validated * typed namedtuples are serialized as lists - .. versionchanged: 24.1.0 + .. versionchanged:: 24.1.0 Add support for typed namedtuples. """ converter.register_unstructure_hook( diff --git a/src/cattrs/preconf/tomlkit.py b/src/cattrs/preconf/tomlkit.py index ace6c360..802df9b6 100644 --- a/src/cattrs/preconf/tomlkit.py +++ b/src/cattrs/preconf/tomlkit.py @@ -15,6 +15,8 @@ from ..strategies import configure_union_passthrough from . import validate_datetime, wrap +__all__ = ["TomlkitConverter", "configure_converter", "make_converter"] + T = TypeVar("T") _enum_value_getter = attrgetter("_value_") diff --git a/src/cattrs/preconf/ujson.py b/src/cattrs/preconf/ujson.py index bc9b1084..8f330615 100644 --- a/src/cattrs/preconf/ujson.py +++ b/src/cattrs/preconf/ujson.py @@ -13,6 +13,8 @@ from ..strategies import configure_union_passthrough from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +__all__ = ["UjsonConverter", "configure_converter", "make_converter"] + T = TypeVar("T") @@ -33,7 +35,7 @@ def configure_converter(converter: BaseConverter): * sets are serialized as lists * string and int enums are passed through when unstructuring - .. versionchanged: 24.2.0 + .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ converter.register_unstructure_hook( diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index 06a92afa..47f3e7de 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -84,7 +84,7 @@ def include_subclasses( def _include_subclasses_without_union_strategy( cl, converter: BaseConverter, - parent_subclass_tree: tuple[type], + parent_subclass_tree: tuple[type, ...], overrides: dict[str, AttributeOverride] | None, ): # The iteration approach is required if subclasses are more than one level deep: diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index c8872019..816a2620 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -7,9 +7,9 @@ from cattrs._compat import get_newtype_base, is_literal, is_subclass, is_union_type __all__ = [ - "default_tag_generator", "configure_tagged_union", "configure_union_passthrough", + "default_tag_generator", ] diff --git a/src/cattrs/typealiases.py b/src/cattrs/typealiases.py new file mode 100644 index 00000000..d3a20c48 --- /dev/null +++ b/src/cattrs/typealiases.py @@ -0,0 +1,57 @@ +"""Utilities for type aliases.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from ._compat import is_generic +from ._generics import deep_copy_with +from .dispatch import StructureHook +from .gen._generics import generate_mapping + +if TYPE_CHECKING: + from .converters import BaseConverter + +__all__ = ["get_type_alias_base", "is_type_alias", "type_alias_structure_factory"] + +if sys.version_info >= (3, 12): + from types import GenericAlias + from typing import TypeAliasType + + def is_type_alias(type: Any) -> bool: + """Is this a PEP 695 type alias?""" + return isinstance( + type.__origin__ if type.__class__ is GenericAlias else type, TypeAliasType + ) + +else: + + 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? + + Works only on 3.12+. + """ + return type.__value__ + + +def type_alias_structure_factory(type: Any, converter: BaseConverter) -> StructureHook: + base = get_type_alias_base(type) + if is_generic(type): + mapping = generate_mapping(type) + if base.__name__ in mapping: + # Probably just type T = T + base = mapping[base.__name__] + else: + base = deep_copy_with(base, mapping) + res = converter.get_structure_hook(base) + if res == converter._structure_call: + # we need to replace the type arg of `structure_call` + return lambda v, _, __base=base: __base(v) + return lambda v, _, __base=base: res(v, __base) diff --git a/src/cattrs/v.py b/src/cattrs/v.py index 5c40310d..134c990f 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -47,12 +47,6 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: ): # This was supposed to be a mapping (and have .items()) but it something else. res = "expected a mapping" - elif isinstance(exc, AttributeError) and exc.args[0].endswith( - "object has no attribute 'copy'" - ): - # This was supposed to be a mapping (and have .copy()) but it something else. - # Used for TypedDicts. - res = "expected a mapping" else: res = f"unknown error ({exc})" diff --git a/tests/__init__.py b/tests/__init__.py index 9d678465..01b82519 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,9 @@ import os +from typing import Literal from hypothesis import HealthCheck, settings from hypothesis.strategies import just, one_of +from typing_extensions import TypeAlias from cattrs import UnstructureStrategy @@ -9,7 +11,9 @@ "CI", settings(suppress_health_check=[HealthCheck.too_slow]), deadline=None ) -if "CI" in os.environ: +if "CI" in os.environ: # pragma: nocover settings.load_profile("CI") unstructure_strats = one_of(just(s) for s in UnstructureStrategy) + +FeatureFlag: TypeAlias = Literal["always", "never", "sometimes"] diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index 7b6b9861..02746305 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -1,13 +1,12 @@ import typing from copy import deepcopy from functools import partial -from typing import List, Tuple import pytest from attrs import define from cattrs import Converter, override -from cattrs.errors import ClassValidationError +from cattrs.errors import ClassValidationError, StructureHandlerNotFoundError from cattrs.strategies import configure_tagged_union, include_subclasses @@ -148,7 +147,7 @@ def conv_w_subclasses(request): "struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT ) def test_structuring_with_inheritance( - conv_w_subclasses: Tuple[Converter, bool], struct_unstruct + conv_w_subclasses: tuple[Converter, bool], struct_unstruct ) -> None: structured, unstructured = struct_unstruct @@ -219,7 +218,7 @@ def test_circular_reference(conv_w_subclasses): "struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT ) def test_unstructuring_with_inheritance( - conv_w_subclasses: Tuple[Converter, bool], struct_unstruct + conv_w_subclasses: tuple[Converter, bool], struct_unstruct ): structured, unstructured = struct_unstruct converter, included_subclasses_param = conv_w_subclasses @@ -389,5 +388,27 @@ class Derived(A): "_type": "Derived", } ], - List[A], + list[A], ) == [Derived(9, Derived(99, A(999)))] + + +def test_unsupported_class(genconverter: Converter): + """Non-attrs/dataclass classes raise proper errors.""" + + class NewParent: + """Not an attrs class.""" + + a: int + + @define + class NewChild(NewParent): + pass + + @define + class NewChild2(NewParent): + pass + + genconverter.register_structure_hook(NewParent, lambda v, _: NewParent(v)) + + with pytest.raises(StructureHandlerNotFoundError): + include_subclasses(NewParent, genconverter) diff --git a/tests/strategies/test_tagged_unions.py b/tests/strategies/test_tagged_unions.py index abd38fef..ba5ae49b 100644 --- a/tests/strategies/test_tagged_unions.py +++ b/tests/strategies/test_tagged_unions.py @@ -161,7 +161,10 @@ class B: configure_tagged_union(Union[A, B], c, default=A) data = c.unstructure(A(), Union[A, B]) - c.structure(data, Union[A, B]) + assert c.structure(data, Union[A, B]) == A() + + data.pop("_type") + assert c.structure(data, Union[A, B]) == A() def test_nested_sequence_union(): diff --git a/tests/test_converter.py b/tests/test_converter.py index 118d407a..b071ae28 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -16,7 +16,7 @@ ) import pytest -from attrs import Factory, define, fields, has, make_class +from attrs import Factory, define, field, fields, has, make_class from hypothesis import HealthCheck, assume, given, settings from hypothesis.strategies import booleans, just, lists, one_of, sampled_from @@ -27,6 +27,7 @@ ForbiddenExtraKeysError, StructureHandlerNotFoundError, ) +from cattrs.fns import raise_error from cattrs.gen import make_dict_structure_fn, override from ._compat import is_py310_plus @@ -423,9 +424,9 @@ def test_type_overrides(cl_and_vals): inst = cl(*vals, **kwargs) unstructured = converter.unstructure(inst) - for field, val in zip(fields(cl), vals): - if field.type is int and field.default is not None and field.default == val: - assert field.name not in unstructured + for attr, val in zip(fields(cl), vals): + if attr.type is int and attr.default is not None and attr.default == val: + assert attr.name not in unstructured def test_calling_back(): @@ -626,7 +627,7 @@ class C: ) expected = seq_type(C(a=cl(*vals), b=cl(*vals)) for _ in range(5)) - assert type(outputs) == seq_type + assert type(outputs) is seq_type assert outputs == expected @@ -744,6 +745,35 @@ class Test: assert isinstance(c.structure({}, Test), Test) +def test_legacy_structure_fallbacks(converter_cls: Type[BaseConverter]): + """Restoring legacy behavior works.""" + + class Test: + """Unsupported by default.""" + + def __init__(self, a): + self.a = a + + c = converter_cls( + structure_fallback_factory=lambda _: raise_error, detailed_validation=False + ) + + # We can get the hook, but... + hook = c.get_structure_hook(Test) + + # it won't work. + with pytest.raises(StructureHandlerNotFoundError): + hook({}, Test) + + # If a field has a converter, we honor that instead. + @define + class Container: + a: Test = field(converter=Test) + + hook = c.get_structure_hook(Container) + hook({"a": 1}, Container) + + def test_fallback_chaining(converter_cls: Type[BaseConverter]): """Converters can be chained using fallback hooks.""" diff --git a/tests/test_converter_inheritance.py b/tests/test_converter_inheritance.py index 6f4739e3..27c68ade 100644 --- a/tests/test_converter_inheritance.py +++ b/tests/test_converter_inheritance.py @@ -1,5 +1,5 @@ import collections -import typing +from typing import Hashable, Iterable, Reversible import pytest from attrs import define @@ -41,9 +41,7 @@ class B(A): assert converter.structure({"i": 1}, B) == B(2) -@pytest.mark.parametrize( - "typing_cls", [typing.Hashable, typing.Iterable, typing.Reversible] -) +@pytest.mark.parametrize("typing_cls", [Hashable, Iterable, Reversible]) def test_inherit_typing(converter: BaseConverter, typing_cls): """Stuff from typing.* resolves to runtime to collections.abc.*. diff --git a/tests/test_defaultdicts.py b/tests/test_defaultdicts.py index 02a34637..00539445 100644 --- a/tests/test_defaultdicts.py +++ b/tests/test_defaultdicts.py @@ -4,6 +4,7 @@ from typing import DefaultDict from cattrs import Converter +from cattrs.cols import defaultdict_structure_factory def test_typing_defaultdicts(genconverter: Converter): @@ -30,3 +31,16 @@ def test_collection_defaultdicts(genconverter: Converter): genconverter.register_unstructure_hook(int, str) assert genconverter.unstructure(res) == {"a": "1", "b": "0"} + + +def test_factory(genconverter: Converter): + """Explicit factories work.""" + genconverter.register_structure_hook_func( + lambda t: t == defaultdict[str, int], + defaultdict_structure_factory(defaultdict[str, int], genconverter, lambda: 2), + ) + res = genconverter.structure({"a": 1}, defaultdict[str, int]) + + assert isinstance(res, defaultdict) + assert res["a"] == 1 + assert res["b"] == 2 diff --git a/tests/test_disambiguators.py b/tests/test_disambiguators.py index a9db5c91..6f549ce0 100644 --- a/tests/test_disambiguators.py +++ b/tests/test_disambiguators.py @@ -10,6 +10,7 @@ from cattrs import Converter from cattrs.disambiguators import create_default_dis_func, is_supported_union +from cattrs.errors import StructureHandlerNotFoundError from cattrs.gen import make_dict_structure_fn, override from .untyped import simple_classes @@ -76,7 +77,40 @@ class H: with pytest.raises(TypeError): # The discriminator chosen does not actually help - create_default_dis_func(c, C, D) + create_default_dis_func(c, G, H) + + # Not an attrs class or dataclass + class J: + i: int + + with pytest.raises(StructureHandlerNotFoundError): + c.get_structure_hook(Union[A, J]) + + @define + class K: + x: Literal[2] + + fn = create_default_dis_func(c, G, K) + with pytest.raises(ValueError): + # The input should be a mapping + fn([]) + + # A normal class with a required attribute + @define + class L: + b: str + + # C and L both have a required attribute, so there will be no fallback. + fn = create_default_dis_func(c, C, L) + with pytest.raises(ValueError): + # We can't disambiguate based on this payload, so we error + fn({"c": 1}) + + # A has no attributes, so it ends up being the fallback + fn = create_default_dis_func(c, A, C) + with pytest.raises(ValueError): + # The input should be a mapping + fn([]) @given(simple_classes(defaults=False)) @@ -96,10 +130,7 @@ class A: assert fn({}) is A assert fn(asdict(cl(*vals, **kwargs))) is cl - attr_names = {a.name for a in fields(cl)} - - if "xyz" not in attr_names: - assert fn({"xyz": 1}) is A # Uses the fallback. + assert fn({"xyz": 1}) is A # Uses the fallback. @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @@ -232,6 +263,23 @@ class D: assert no_lits({"a": "a"}) is D +def test_default_none(): + """The default disambiguator can handle `None`.""" + c = Converter() + + @define + class A: + a: int + + @define + class B: + b: str + + hook = c.get_structure_hook(Union[A, B, None]) + assert hook({"a": 1}, Union[A, B, None]) == A(1) + assert hook(None, Union[A, B, None]) is None + + def test_converter_no_literals(converter: Converter): """A converter can be configured to skip literals.""" diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 6bb61a6b..6dd68503 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -16,7 +16,7 @@ from .untyped import nested_classes, simple_classes -@given(nested_classes | simple_classes()) +@given(nested_classes() | simple_classes()) def test_unmodified_generated_unstructuring(cl_and_vals): converter = BaseConverter() cl, vals, kwargs = cl_and_vals @@ -33,7 +33,7 @@ def test_unmodified_generated_unstructuring(cl_and_vals): assert res_expected == res_actual -@given(nested_classes | simple_classes()) +@given(nested_classes() | simple_classes()) def test_nodefs_generated_unstructuring(cl_and_vals): """Test omitting default values on a per-attribute basis.""" converter = BaseConverter() @@ -61,7 +61,9 @@ def test_nodefs_generated_unstructuring(cl_and_vals): assert attr.name not in res -@given(one_of(just(BaseConverter), just(Converter)), nested_classes | simple_classes()) +@given( + one_of(just(BaseConverter), just(Converter)), nested_classes() | simple_classes() +) def test_nodefs_generated_unstructuring_cl( converter_cls: Type[BaseConverter], cl_and_vals ): @@ -105,7 +107,7 @@ def test_nodefs_generated_unstructuring_cl( @given( one_of(just(BaseConverter), just(Converter)), - nested_classes | simple_classes() | simple_typed_dataclasses(), + nested_classes() | simple_classes() | simple_typed_dataclasses(), ) def test_individual_overrides(converter_cls, cl_and_vals): """ @@ -558,6 +560,7 @@ def test_init_false_no_structure_hook(converter: BaseConverter): @define class A: a: int = field(converter=int, init=False) + b: int = field(converter=int, init=False, default=5) converter.register_structure_hook( A, @@ -636,8 +639,8 @@ class A: converter.structure({"a": "a"}, A) -@given(prefer=...) -def test_prefer_converters_from_converter(prefer: bool): +@given(prefer=..., dv=...) +def test_prefer_converters_from_converter(prefer: bool, dv: bool): """ `prefer_attrs_converters` is taken from the converter by default. """ @@ -645,13 +648,17 @@ def test_prefer_converters_from_converter(prefer: bool): @define class A: a: int = field(converter=lambda x: x + 1) + b: int = field(converter=lambda x: x + 1, default=5) converter = BaseConverter(prefer_attrib_converters=prefer) converter.register_structure_hook(int, lambda x, _: x + 1) - converter.register_structure_hook(A, make_dict_structure_fn(A, converter)) + converter.register_structure_hook( + A, make_dict_structure_fn(A, converter, _cattrs_detailed_validation=dv) + ) if prefer: - assert converter.structure({"a": 1}, A).a == 2 + assert converter.structure({"a": 1, "b": 2}, A).a == 2 + assert converter.structure({"a": 1, "b": 2}, A).b == 3 else: assert converter.structure({"a": 1}, A).a == 3 diff --git a/tests/test_generics.py b/tests/test_generics.py index 429c155c..466c4134 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -201,6 +201,30 @@ class OuterStr: assert genconverter.structure(raw, OuterStr) == OuterStr(Inner("1")) +def test_unstructure_generic_inheritance(genconverter): + """Classes inheriting from generic classes work.""" + genconverter.register_unstructure_hook(int, lambda v: v + 1) + genconverter.register_unstructure_hook(str, lambda v: str(int(v) + 1)) + + @define + class Parent(Generic[T]): + a: T + + @define + class Child(Parent, Generic[T]): + b: str + + instance = Child(1, "2") + assert genconverter.unstructure(instance, Child[int]) == {"a": 2, "b": "3"} + + @define + class ExplicitChild(Parent[int]): + b: str + + instance = ExplicitChild(1, "2") + assert genconverter.unstructure(instance, ExplicitChild) == {"a": 2, "b": "3"} + + def test_unstructure_optional(genconverter): """Generics with optional fields work.""" diff --git a/tests/test_generics_695.py b/tests/test_generics_695.py index 380d8e25..bfb28dc1 100644 --- a/tests/test_generics_695.py +++ b/tests/test_generics_695.py @@ -89,3 +89,19 @@ def structure_testclass(val, type): type TestAlias = TestClass assert converter.structure(None, TestAlias) is TestClass + + +def test_generic_type_alias(converter: BaseConverter): + """Generic type aliases work. + + See https://docs.python.org/3/reference/compound_stmts.html#generic-type-aliases + for details. + """ + + type Gen1[T] = T + + assert converter.structure("1", Gen1[int]) == 1 + + type Gen2[K, V] = dict[K, V] + + assert converter.structure({"a": "1"}, Gen2[str, int]) == {"a": 1} diff --git a/tests/test_generics_696.py b/tests/test_generics_696.py index c56c894f..9cb0ddfa 100644 --- a/tests/test_generics_696.py +++ b/tests/test_generics_696.py @@ -30,7 +30,7 @@ class C(Generic[T]): c_mapping = generate_mapping(C[str]) atype = fields(C[str]).a.type - assert c_mapping[atype.__name__] == str + assert c_mapping[atype.__name__] is str assert genconverter.structure({"a": "1"}, C[str]) == C("1") @@ -40,10 +40,10 @@ class D(Generic[TD]): d_mapping = generate_mapping(D) atype = fields(D).a.type - assert d_mapping[atype.__name__] == str + assert d_mapping[atype.__name__] is str # Defaults to string - assert d_mapping[atype.__name__] == str + assert d_mapping[atype.__name__] is str assert genconverter.structure({"a": "1"}, D) == D("1") # But allows other types diff --git a/tests/test_preconf.py b/tests/test_preconf.py index fec750ff..0197ad0f 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -88,6 +88,7 @@ class ABareEnum(Enum): an_int: int a_float: float a_dict: Dict[str, int] + a_bare_dict: dict a_list: List[int] a_homogenous_tuple: TupleSubscriptable[int, ...] a_hetero_tuple: TupleSubscriptable[str, int, float] @@ -160,6 +161,7 @@ def everythings( draw(ints), draw(fs), draw(dictionaries(key_text, ints)), + draw(dictionaries(key_text, strings)), draw(lists(ints)), tuple(draw(lists(ints))), (draw(strings), draw(ints), draw(fs)), @@ -196,8 +198,6 @@ def everythings( def native_unions( draw: DrawFn, include_strings=True, - include_bools=True, - include_ints=True, include_floats=True, include_nones=True, include_bytes=True, @@ -205,17 +205,11 @@ def native_unions( include_objectids=False, include_literals=True, ) -> tuple[Any, Any]: - types = [] - strats = {} + types = [bool, int] + strats = {bool: booleans(), int: integers()} if include_strings: types.append(str) strats[str] = text() - if include_bools: - types.append(bool) - strats[bool] = booleans() - if include_ints: - types.append(int) - strats[int] = integers() if include_floats: types.append(float) strats[float] = floats(allow_nan=False) diff --git a/tests/test_tests.py b/tests/test_tests.py new file mode 100644 index 00000000..bae1ac8f --- /dev/null +++ b/tests/test_tests.py @@ -0,0 +1,9 @@ +from .untyped import gen_attr_names + + +def test_gen_attr_names(): + """We can generate a lot of attribute names.""" + assert len(list(gen_attr_names())) == 697 + + # No duplicates! + assert len(list(gen_attr_names())) == len(set(gen_attr_names())) diff --git a/tests/test_tuples.py b/tests/test_tuples.py index a6729abc..4fcfd85e 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -69,19 +69,25 @@ class Test(NamedTuple): def test_simple_dict_nametuples(genconverter: Converter): """Namedtuples can be un/structured to/from dicts.""" + class TestInner(NamedTuple): + a: int + class Test(NamedTuple): a: int b: str = "test" + c: TestInner = TestInner(1) genconverter.register_unstructure_hook_factory( - lambda t: t is Test, namedtuple_dict_unstructure_factory + lambda t: t in (Test, TestInner), namedtuple_dict_unstructure_factory ) genconverter.register_structure_hook_factory( - lambda t: t is Test, namedtuple_dict_structure_factory + lambda t: t in (Test, TestInner), namedtuple_dict_structure_factory ) - assert genconverter.unstructure(Test(1)) == {"a": 1, "b": "test"} - assert genconverter.structure({"a": 1, "b": "2"}, Test) == Test(1, "2") + assert genconverter.unstructure(Test(1)) == {"a": 1, "b": "test", "c": {"a": 1}} + assert genconverter.structure({"a": 1, "b": "2"}, Test) == Test( + 1, "2", TestInner(1) + ) # Defaults work. assert genconverter.structure({"a": 1}, Test) == Test(1, "test") diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index 492750c8..35085444 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -10,7 +10,7 @@ from pytest import raises from typing_extensions import NotRequired, Required -from cattrs import BaseConverter, Converter +from cattrs import BaseConverter, Converter, transform_error from cattrs._compat import ExtensionsTypedDict, get_notrequired_base, is_generic from cattrs.errors import ( ClassValidationError, @@ -27,12 +27,21 @@ from ._compat import is_py311_plus from .typeddicts import ( + gen_typeddict_attr_names, generic_typeddicts, simple_typeddicts, simple_typeddicts_with_extra_keys, ) +def test_gen_attr_names(): + """We can generate a lot of attribute names.""" + assert len(list(gen_typeddict_attr_names())) == 697 + + # No duplicates! + assert len(list(gen_typeddict_attr_names())) == len(set(gen_typeddict_attr_names())) + + def mk_converter(detailed_validation: bool = True) -> Converter: """We can't use function-scoped fixtures with Hypothesis strats.""" c = Converter(detailed_validation=detailed_validation) @@ -60,7 +69,7 @@ def get_annot(t) -> dict: NotRequired[param_to_args[nrb]] if nrb in param_to_args else v ) else: - res[k] = param_to_args[v] if v in param_to_args else v + res[k] = param_to_args.get(v, v) return res # Origin is `None`, so this is a subclass for a generic typeddict. @@ -72,7 +81,7 @@ def get_annot(t) -> dict: NotRequired[mapping[nrb.__name__]] if nrb.__name__ in mapping else v ) else: - res[k] = mapping[v.__name__] if v.__name__ in mapping else v + res[k] = mapping.get(v.__name__, v) return res return get_annots(t) @@ -500,3 +509,21 @@ class A(ExtensionsTypedDict): assert converter.unstructure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} assert converter.structure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} + + +def test_nondict_input(): + """Trying to structure typeddict from a non-dict raises the proper exception.""" + converter = Converter(detailed_validation=True) + with raises(ClassValidationError) as exc: + converter.structure(1, TypedDictA) + + assert transform_error(exc.value) == [ + "invalid type (expected a mapping, not int) @ $" + ] + + with raises(ClassValidationError) as exc: + converter.structure([1], TypedDictA) + + assert transform_error(exc.value) == [ + "invalid type (expected a mapping, not list) @ $" + ] diff --git a/tests/test_unions.py b/tests/test_unions.py index 1f56a75c..18b8eb6b 100644 --- a/tests/test_unions.py +++ b/tests/test_unions.py @@ -1,4 +1,4 @@ -from typing import Type, Union +from typing import Union import pytest from attrs import define @@ -9,7 +9,7 @@ @pytest.mark.parametrize("cls", (BaseConverter, Converter)) -def test_custom_union_toplevel_roundtrip(cls: Type[BaseConverter]): +def test_custom_union_toplevel_roundtrip(cls: type[BaseConverter]): """ Test custom code union handling. @@ -42,7 +42,7 @@ class B: @pytest.mark.skipif(not is_py310_plus, reason="3.10 union syntax") @pytest.mark.parametrize("cls", (BaseConverter, Converter)) -def test_310_custom_union_toplevel_roundtrip(cls: Type[BaseConverter]): +def test_310_custom_union_toplevel_roundtrip(cls: type[BaseConverter]): """ Test custom code union handling. @@ -74,7 +74,7 @@ class B: @pytest.mark.parametrize("cls", (BaseConverter, Converter)) -def test_custom_union_clsfield_roundtrip(cls: Type[BaseConverter]): +def test_custom_union_clsfield_roundtrip(cls: type[BaseConverter]): """ Test custom code union handling. diff --git a/tests/test_unstructure.py b/tests/test_unstructure.py index d290e66a..3f9b52da 100644 --- a/tests/test_unstructure.py +++ b/tests/test_unstructure.py @@ -1,6 +1,6 @@ """Tests for dumping.""" -from attr import asdict, astuple +from attrs import asdict, astuple from hypothesis import given from hypothesis.strategies import data, just, lists, one_of, sampled_from @@ -69,7 +69,7 @@ def test_enum_unstructure(enum, dump_strat, data): assert converter.unstructure(member) == member.value -@given(nested_classes) +@given(nested_classes()) def test_attrs_asdict_unstructure(nested_class): """Our dumping should be identical to `attrs`.""" converter = BaseConverter() @@ -77,7 +77,7 @@ def test_attrs_asdict_unstructure(nested_class): assert converter.unstructure(instance) == asdict(instance) -@given(nested_classes) +@given(nested_classes()) def test_attrs_astuple_unstructure(nested_class): """Our dumping should be identical to `attrs`.""" converter = BaseConverter(unstruct_strat=UnstructureStrategy.AS_TUPLE) @@ -130,5 +130,5 @@ def test_seq_of_simple_classes_unstructure(cls_and_vals, seq_type: type): inputs = seq_type(cl(*vals, **kwargs) for cl, vals, kwargs in cls_and_vals) outputs = converter.unstructure(inputs) - assert type(outputs) == seq_type - assert all(type(e) is dict for e in outputs) # noqa: E721 + assert type(outputs) is seq_type + assert all(type(e) is dict for e in outputs) diff --git a/tests/test_v.py b/tests/test_v.py index 4aa97164..513027c6 100644 --- a/tests/test_v.py +++ b/tests/test_v.py @@ -8,13 +8,15 @@ Optional, Sequence, Tuple, + TypedDict, ) from attrs import Factory, define, field from pytest import fixture, raises from cattrs import Converter, transform_error -from cattrs._compat import Mapping, TypedDict +from cattrs._compat import Mapping +from cattrs.errors import IterableValidationError from cattrs.gen import make_dict_structure_fn from cattrs.v import format_exception @@ -22,7 +24,7 @@ @fixture def c() -> Converter: """We need only converters with detailed_validation=True.""" - return Converter() + return Converter(detailed_validation=True) def test_attribute_errors(c: Converter) -> None: @@ -190,6 +192,11 @@ class C: "invalid value for type, expected int @ $.b[1][2]", ] + # IterableValidationErrors with subexceptions without notes + exc = IterableValidationError("Test", [TypeError("Test")], list[str]) + + assert transform_error(exc) == ["invalid type (Test) @ $"] + def test_mapping_errors(c: Converter) -> None: try: @@ -316,7 +323,9 @@ class D(TypedDict): try: c.structure({"c": 1}, D) except Exception as exc: - assert transform_error(exc) == ["expected a mapping @ $.c"] + assert transform_error(exc) == [ + "invalid type (expected a mapping, not int) @ $.c" + ] try: c.structure({"c": {"a": "str"}}, D) diff --git a/tests/test_validation.py b/tests/test_validation.py index 53027e31..e8bed5a7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -57,7 +57,7 @@ class Test: with pytest.raises(ClassValidationError) as exc: c.structure({"a": 1, "b": "c", "c": "1"}, Test) - assert type(exc.value.exceptions[0]) == ValueError + assert type(exc.value.exceptions[0]) is ValueError assert str(exc.value.exceptions[0].args[0]) == "'b' must be in ['a', 'b'] (got 'c')" @@ -222,3 +222,59 @@ def test_notes_pickling(): assert note == "foo" assert note.name == "name" assert note.type is int + + +def test_error_derive(): + """Our ExceptionGroups should derive properly.""" + c = Converter(detailed_validation=True) + + @define + class Test: + a: int + b: str = field(validator=in_(["a", "b"])) + c: str + + with pytest.raises(ClassValidationError) as exc: + c.structure({"a": "a", "b": "c"}, Test) + + match, rest = exc.value.split(KeyError) + + assert len(match.exceptions) == 1 + assert len(rest.exceptions) == 1 + + assert match.cl == exc.value.cl + assert rest.cl == exc.value.cl + + +def test_iterable_note_grouping(): + """IterableValidationErrors can group their subexceptions by notes.""" + exc1 = ValueError() + exc2 = KeyError() + exc3 = TypeError() + + exc2.__notes__ = [note := IterableValidationNote("Test Note", 0, int)] + exc3.__notes__ = ["A string note"] + + exc = IterableValidationError("Test", [exc1, exc2, exc3], list[int]) + + with_notes, without_notes = exc.group_exceptions() + + assert with_notes == [(exc2, note)] + assert without_notes == [exc1, exc3] + + +def test_class_note_grouping(): + """ClassValidationErrors can group their subexceptions by notes.""" + exc1 = ValueError() + exc2 = KeyError() + exc3 = TypeError() + + exc2.__notes__ = [note := AttributeValidationNote("Test Note", "a", int)] + exc3.__notes__ = ["A string note"] + + exc = ClassValidationError("Test", [exc1, exc2, exc3], int) + + with_notes, without_notes = exc.group_exceptions() + + assert with_notes == [(exc2, note)] + assert without_notes == [exc1, exc3] diff --git a/tests/typed.py b/tests/typed.py index 7c88dd34..5ff4ea6f 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -1,6 +1,5 @@ """Strategies for attributes with types and classes using them.""" -from collections import OrderedDict from collections.abc import MutableSequence as AbcMutableSequence from collections.abc import MutableSet as AbcMutableSet from collections.abc import Sequence as AbcSequence @@ -27,7 +26,7 @@ ) from attr._make import _CountingAttr -from attrs import NOTHING, Factory, field, frozen +from attrs import NOTHING, AttrsInstance, Factory, field, frozen from hypothesis import note from hypothesis.strategies import ( DrawFn, @@ -293,7 +292,7 @@ def key(t): attr_name = attr_name[1:] kwarg_strats[attr_name] = attr_and_strat[1] return tuples( - just(make_class("HypClass", OrderedDict(zip(gen_attr_names(), attrs)))), + just(make_class("HypClass", dict(zip(gen_attr_names(), attrs)))), just(tuples(*vals)), just(fixed_dictionaries(kwarg_strats)), ) @@ -401,8 +400,8 @@ def path_typed_attrs( @composite def dict_typed_attrs( - draw, defaults=None, allow_mutable_defaults=True, kw_only=None -) -> SearchStrategy[tuple[_CountingAttr, SearchStrategy]]: + draw: DrawFn, defaults=None, allow_mutable_defaults=True, kw_only=None +) -> tuple[_CountingAttr, SearchStrategy[dict[str, int]]]: """ Generate a tuple of an attribute and a strategy that yields dictionaries for that attribute. The dictionaries map strings to integers. @@ -820,7 +819,7 @@ def nested_classes( tuple[type, SearchStrategy[PosArgs], SearchStrategy[KwArgs]], ] ], -) -> SearchStrategy[tuple[Type, SearchStrategy[PosArgs], SearchStrategy[KwArgs]]]: +) -> tuple[type[AttrsInstance], SearchStrategy[PosArgs], SearchStrategy[KwArgs]]: attrs, class_and_strat = draw(attrs_and_classes) cls, strat, kw_strat = class_and_strat pos_defs = tuple(draw(strat)) @@ -860,7 +859,12 @@ def nested_typed_classes_and_strat( @composite def nested_typed_classes( - draw, defaults=None, min_attrs=0, kw_only=None, newtypes=True, allow_nan=True + draw: DrawFn, + defaults=None, + min_attrs=0, + kw_only=None, + newtypes=True, + allow_nan=True, ): cl, strat, kwarg_strat = draw( nested_typed_classes_and_strat( diff --git a/tests/typeddicts.py b/tests/typeddicts.py index f44d8bf9..ba488010 100644 --- a/tests/typeddicts.py +++ b/tests/typeddicts.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from string import ascii_lowercase -from typing import Any, Generic, List, Optional, TypeVar +from typing import Any, Generic, List, Optional, TypedDict, TypeVar from attrs import NOTHING from hypothesis import note @@ -19,13 +19,7 @@ text, ) -from cattrs._compat import ( - Annotated, - ExtensionsTypedDict, - NotRequired, - Required, - TypedDict, -) +from cattrs._compat import Annotated, ExtensionsTypedDict, NotRequired, Required from .untyped import gen_attr_names @@ -38,10 +32,7 @@ def gen_typeddict_attr_names(): """Typed dicts can have periods in their field names.""" - counter = 0 - for n in gen_attr_names(): - counter += 1 - + for counter, n in enumerate(gen_attr_names()): if counter % 2 == 0: n = f"{n}.suffix" @@ -280,8 +271,7 @@ def make_typeddict( bases_snippet = ", ".join(f"_base{ix}" for ix in range(len(bases))) for ix, base in enumerate(bases): globs[f"_base{ix}"] = base - if bases_snippet: - bases_snippet = f", {bases_snippet}" + bases_snippet = f", {bases_snippet}" lines.append(f"class {cls_name}(TypedDict{bases_snippet}, total={total}):") for n, t in attrs.items(): diff --git a/tests/untyped.py b/tests/untyped.py index 23f39c8c..7b0dab95 100644 --- a/tests/untyped.py +++ b/tests/untyped.py @@ -2,7 +2,6 @@ import keyword import string -from collections import OrderedDict from enum import Enum from typing import ( Any, @@ -23,11 +22,15 @@ from attr._make import _CountingAttr from attrs import NOTHING, AttrsInstance, Factory, make_class from hypothesis import strategies as st -from hypothesis.strategies import SearchStrategy +from hypothesis.strategies import SearchStrategy, booleans +from typing_extensions import TypeAlias + +from . import FeatureFlag PosArg = Any PosArgs = tuple[PosArg] KwArgs = dict[str, Any] +AttrsAndArgs: TypeAlias = tuple[type[AttrsInstance], PosArgs, KwArgs] primitive_strategies = st.sampled_from( [ @@ -167,7 +170,7 @@ def gen_attr_names() -> Iterable[str]: def _create_hyp_class( attrs_and_strategy: list[tuple[_CountingAttr, st.SearchStrategy[PosArgs]]], frozen=None, -): +) -> SearchStrategy[AttrsAndArgs]: """ A helper function for Hypothesis to generate attrs classes. @@ -192,7 +195,7 @@ def key(t): return st.tuples( st.builds( lambda f: make_class( - "HypClass", OrderedDict(zip(gen_attr_names(), attrs)), frozen=f + "HypClass", dict(zip(gen_attr_names(), attrs)), frozen=f ), st.booleans() if frozen is None else st.just(frozen), ), @@ -209,26 +212,28 @@ def just_class(tup): return _create_hyp_class(combined_attrs) -def just_class_with_type(tup): +def just_class_with_type(tup: tuple) -> SearchStrategy[AttrsAndArgs]: nested_cl = tup[1][0] - default = attr.Factory(nested_cl) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) - ) - return _create_hyp_class(combined_attrs) + def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: + combined_attrs = list(tup[0]) + combined_attrs.append( + ( + attr.ib( + default=( + Factory( + nested_cl if not takes_self else lambda _: nested_cl(), + takes_self=takes_self, + ) + ), + type=nested_cl, + ), + st.just(nested_cl()), + ) + ) + return _create_hyp_class(combined_attrs) -def just_class_with_type_takes_self( - tup: tuple[list[tuple[_CountingAttr, SearchStrategy]], tuple[type[AttrsInstance]]] -) -> SearchStrategy[tuple[type[AttrsInstance]]]: - nested_cl = tup[1][0] - default = Factory(lambda _: nested_cl(), takes_self=True) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) - ) - return _create_hyp_class(combined_attrs) + return booleans().flatmap(make_with_default) def just_frozen_class_with_type(tup): @@ -240,22 +245,45 @@ def just_frozen_class_with_type(tup): return _create_hyp_class(combined_attrs) -def list_of_class(tup): +def list_of_class(tup: tuple) -> SearchStrategy[AttrsAndArgs]: nested_cl = tup[1][0] - default = attr.Factory(lambda: [nested_cl()]) - combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just([nested_cl()]))) - return _create_hyp_class(combined_attrs) + def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: + combined_attrs = list(tup[0]) + combined_attrs.append( + ( + attr.ib( + default=( + Factory(lambda: [nested_cl()]) + if not takes_self + else Factory(lambda _: [nested_cl()], takes_self=True) + ), + type=list[nested_cl], + ), + st.just([nested_cl()]), + ) + ) + return _create_hyp_class(combined_attrs) + + return booleans().flatmap(make_with_default) -def list_of_class_with_type(tup): + +def list_of_class_with_type(tup: tuple) -> SearchStrategy[AttrsAndArgs]: nested_cl = tup[1][0] - default = attr.Factory(lambda: [nested_cl()]) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=List[nested_cl]), st.just([nested_cl()])) - ) - return _create_hyp_class(combined_attrs) + + def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: + default = ( + Factory(lambda: [nested_cl()]) + if not takes_self + else Factory(lambda _: [nested_cl()], takes_self=True) + ) + combined_attrs = list(tup[0]) + combined_attrs.append( + (attr.ib(default=default, type=List[nested_cl]), st.just([nested_cl()])) + ) + return _create_hyp_class(combined_attrs) + + return booleans().flatmap(make_with_default) def dict_of_class(tup): @@ -266,7 +294,9 @@ def dict_of_class(tup): return _create_hyp_class(combined_attrs) -def _create_hyp_nested_strategy(simple_class_strategy): +def _create_hyp_nested_strategy( + simple_class_strategy: SearchStrategy, +) -> SearchStrategy: """ Create a recursive attrs class. Given a strategy for building (simpler) classes, create and return @@ -275,6 +305,7 @@ def _create_hyp_nested_strategy(simple_class_strategy): * a list of simpler classes * a dict mapping the string "cls" to a simpler class. """ + # A strategy producing tuples of the form ([list of attributes], ). attrs_and_classes = st.tuples(lists_of_attrs(defaults=True), simple_class_strategy) @@ -286,7 +317,6 @@ def _create_hyp_nested_strategy(simple_class_strategy): | attrs_and_classes.flatmap(list_of_class_with_type) | attrs_and_classes.flatmap(dict_of_class) | attrs_and_classes.flatmap(just_frozen_class_with_type) - | attrs_and_classes.flatmap(just_class_with_type_takes_self) ) @@ -430,9 +460,10 @@ def simple_classes(defaults=None, min_attrs=0, frozen=None, kw_only=None): ) -# Ok, so st.recursive works by taking a base strategy (in this case, -# simple_classes) and a special function. This function receives a strategy, -# and returns another strategy (building on top of the base strategy). -nested_classes = st.recursive( - simple_classes(defaults=True), _create_hyp_nested_strategy -) +def nested_classes( + takes_self: FeatureFlag = "sometimes", +) -> SearchStrategy[AttrsAndArgs]: + # Ok, so st.recursive works by taking a base strategy (in this case, + # simple_classes) and a special function. This function receives a strategy, + # and returns another strategy (building on top of the base strategy). + return st.recursive(simple_classes(defaults=True), _create_hyp_nested_strategy)