diff --git a/HISTORY.rst b/HISTORY.rst index 2ace393a..046f75e0 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,11 @@ ======= History ======= + +22.3.0 (UNRELEASED) +------------------- +* Introduce the `tagged_union` strategy. (`#318 `_ `#317 `_) + 22.2.0 (2022-10-03) ------------------- * *Potentially breaking*: ``cattrs.Converter`` has been renamed to ``cattrs.BaseConverter``, and ``cattrs.GenConverter`` to ``cattrs.Converter``. diff --git a/Makefile b/Makefile index 9c859372..986acfd3 100644 --- a/Makefile +++ b/Makefile @@ -59,10 +59,10 @@ test-all: ## run tests on every Python version with tox tox coverage: ## check code coverage quickly with the default Python - coverage run --source cattr -m pytest + poetry run coverage run --source cattrs -m pytest - coverage report -m - coverage html + poetry run coverage report -m + poetry run coverage html $(BROWSER) htmlcov/index.html docs: ## generate Sphinx HTML documentation, including API docs @@ -77,18 +77,6 @@ docs: ## generate Sphinx HTML documentation, including API docs servedocs: docs ## compile the docs watching for changes watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . -release: clean ## package and upload a release - python setup.py sdist upload - python setup.py bdist_wheel upload - -dist: clean ## builds source and wheel package - python setup.py sdist - python setup.py bdist_wheel - ls -l dist - -install: clean ## install the package to the active Python's site-packages - python setup.py install - bench-cmp: pytest bench --benchmark-compare diff --git a/docs/Makefile b/docs/Makefile index 0d16a4b5..8e795afa 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -175,3 +175,6 @@ pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +apidoc: + sphinx-apidoc -o . ../src/cattrs/ -f \ No newline at end of file diff --git a/docs/_static/custom.css b/docs/_static/custom.css index a90cc5f3..cbb076e4 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,36 +1,50 @@ -@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"); -h2 { - margin-bottom: 0.5em; +h2, +h3 { + margin-bottom: 0.5em; + margin-top: 2rem; } -div.article-container>article { - font-size: 19px; - line-height: 31px; +:target > h1:first-of-type, +:target > h2:first-of-type, +:target > h3:first-of-type, +span:target ~ h1:first-of-type, +span:target ~ h2:first-of-type, +span:target ~ h3:first-of-type, +span:target ~ h4:first-of-type, +span:target ~ h5:first-of-type, +span:target ~ h6:first-of-type { + text-decoration: underline dashed; +} + +div.article-container > article { + font-size: 18px; + line-height: 31px; } div.admonition { - font-size: 15px; - line-height: 27px; - margin-top: 2em; - margin-bottom: 2em; + font-size: 15px; + line-height: 27px; + margin-top: 2em; + margin-bottom: 2em; } p.admonition-title { - font-size: 15px !important; - line-height: 20px !important; + font-size: 15px !important; + line-height: 20px !important; } -article>li>a { - font-size: 19px; - line-height: 31px; +article > li > a { + font-size: 19px; + line-height: 31px; } div.tab-set { - margin-top: 1em; - margin-bottom: 2em; + margin-top: 1em; + margin-bottom: 2em; } div.tab-set pre { - padding: 1.25em; -} \ No newline at end of file + padding: 1.25em; +} diff --git a/docs/cattrs.rst b/docs/cattrs.rst index 2e3d316c..6e8fbdfd 100644 --- a/docs/cattrs.rst +++ b/docs/cattrs.rst @@ -8,6 +8,7 @@ Subpackages :maxdepth: 4 cattrs.preconf + cattrs.strategies Submodules ---------- diff --git a/docs/cattrs.strategies.rst b/docs/cattrs.strategies.rst new file mode 100644 index 00000000..e9a3a2da --- /dev/null +++ b/docs/cattrs.strategies.rst @@ -0,0 +1,21 @@ +cattrs.strategies package +========================= + +Submodules +---------- + +cattrs.strategies.subclasses module +----------------------------------- + +.. automodule:: cattrs.strategies.subclasses + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: cattrs.strategies + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index 4098a10e..323e6911 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,8 @@ "sphinx.ext.viewcode", "sphinx.ext.doctest", "sphinx.ext.autosectionlabel", + "sphinx_copybutton", + "myst_parser", ] # Add any paths that contain templates here, relative to this directory. @@ -121,7 +123,9 @@ "font-stack": "Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji", "font-stack--monospace": "'Ubuntu Mono', monospace", "code-font-size": "90%", - } + "color-highlight-on-target": "transparent", + }, + "dark_css_variables": {"color-highlight-on-target": "transparent"}, } # Add any paths that contain custom themes here, relative to this directory. @@ -285,3 +289,6 @@ ) autodoc_typehints = "description" autosectionlabel_prefix_document = True +copybutton_prompt_text = r">>> |\.\.\. " +copybutton_prompt_is_regexp = True +myst_heading_anchors = 3 diff --git a/docs/index.rst b/docs/index.rst index 9fbf1f18..ca5f2ca4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,6 +8,7 @@ usage structuring unstructuring + strategies validation preconf customizing diff --git a/docs/modules.rst b/docs/modules.rst index 429a504d..637f6d95 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -5,11 +5,3 @@ cattrs :maxdepth: 4 cattrs - -cattr -===== - -.. toctree:: - :maxdepth: 4 - - cattr diff --git a/docs/strategies.md b/docs/strategies.md new file mode 100644 index 00000000..5b4cac76 --- /dev/null +++ b/docs/strategies.md @@ -0,0 +1,121 @@ +# Strategies + +_cattrs_ ships with a number of _strategies_ for customizing un/structuring behavior. + +Strategies are prepackaged, high-level patterns for quickly and easily applying complex customizations to a converter. + +## Tagged Unions + +_Found at {py:func}`cattrs.strategies.configure_tagged_union`._ + +The _tagged union_ strategy allows for un/structuring a union of classes by including an additional field (the _tag_) in the unstructured representation. +Each tag value is associated with a member of the union. + +```{doctest} tagged_unions + +>>> from cattrs.strategies import configure_tagged_union +>>> from cattrs import Converter +>>> converter = Converter() + +>>> @define +... class A: +... a: int + +>>> @define +... class B: +... b: str + +>>> configure_tagged_union(A | B, converter) + +>>> converter.unstructure(A(1), unstructure_as=A | B) +{'a': 1, '_type': 'A'} + +>>> converter.structure({'a': 1, '_type': 'A'}, A | B) +A(a=1) +``` + +By default, the tag field name is `_type` and the tag value is the class name of the union member. +Both the field name and value can be overriden. + +The `tag_generator` parameter is a one-argument callable that will be called with every member of the union to generate a mapping of tag values to union members. +Here are some common `tag_generator` uses: + +| Tag info available in | Recommended `tag_generator` | +| ----------------------------- | ------------------------------------------------------- | +| Name of the class | Use the default, or `lambda cl: cl.__name__` | +| A class variable (`classvar`) | `lambda cl: cl.classvar` | +| A dictionary (`mydict`) | `mydict.get` or `mydict.__getitem__` | +| An enum of possible values | Build a dictionary of classes to enum values and use it | + +The union members aren't required to be attrs classes or dataclasses, although those work automatically. +They may be anything that cattrs can un/structure from/to a dictionary, for example a type with registered custom hooks. + +A default member can be specified to be used if the tag is missing or is unknown. +This is useful for evolving APIs in a backwards-compatible way; an endpoint taking class `A` can be changed to take `A | B` with `A` as the default (for old clients which do not send the tag). + +This strategy only applies in the context of the union; the normal un/structuring hooks are left untouched. +This also means union members can be reused in multiple unions easily. + +```{doctest} tagged_unions + +# Unstructuring as a union. +>>> converter.unstructure(A(1), unstructure_as=A | B) +{'a': 1, '_type': 'A'} + +# Unstructuring as just an `A`. +>>> converter.unstructure(A(1)) +{'a': 1} +``` + +### Real-life Case Study + +The Apple App Store supports [server callbacks](https://developer.apple.com/documentation/appstoreservernotifications), by which Apple sends a JSON payload to a URL of your choice. +The payload can be interpreted as about a dozen different messages, based on the value of the `notificationType` field. + +To keep the example simple we define two classes, one for the `REFUND` event and one for everything else. + +```python + +@define +class Refund: + originalTransactionId: str + +@define +class OtherAppleNotification: + notificationType: str + +AppleNotification = Refund | OtherAppleNotification + +``` + +Next, we use the _tagged unions_ strategy to prepare our converter. +The tag value for the `Refund` event is `REFUND`, and we can let the `OtherAppleNotification` class handle all the other cases. +The `tag_generator` parameter is a callable, so we can give it the `get` method of a dictionary. + +```python + +>>> c = Converter() +>>> configure_tagged_union( +... AppleNotification, +... c, +... tag_name="notificationType", +... tag_generator={Refund: "REFUND"}, +... default=OtherAppleNotification +... ) + +``` + +The converter is now ready to start structuring Apple notifications. + +```python + +>>> payload = {"notificationType": "REFUND", "originalTransactionId": "1"} +>>> notification = c.structure(payload, AppleNotification) + +>>> match notification: +... case Refund(txn_id): +... print(f"Refund for {txn_id}!") +... case OtherAppleNotification(not_type): +... print("Can't handle this yet") + +``` diff --git a/docs/unions.md b/docs/unions.md new file mode 100644 index 00000000..f72a4b8c --- /dev/null +++ b/docs/unions.md @@ -0,0 +1,66 @@ +# Tips for handling unions + +This sections contains information for advanced union handling. + +As mentioned in the structuring section, _cattrs_ is able to handle simple +unions of _attrs_ classes automatically. More complex cases require +converter customization (since there are many ways of handling unions). + +## Unstructuring unions with extra metadata + +```{note} +_cattrs_ comes with the [tagged unions strategy](strategies.md#tagged-unions) for handling this exact use-case since version 22.3. +The example below has been left here for educational purposes, but you should prefer the strategy. +``` + +Let's assume a simple scenario of two classes, `ClassA` and `ClassB`, both +of which have no distinct fields and so cannot be used automatically with +_cattrs_. + +```python +@define +class ClassA: + a_string: str + +@define +class ClassB: + a_string: str +``` + +A naive approach to unstructuring either of these would yield identical +dictionaries, and not enough information to restructure the classes. + +```python +>>> converter.unstructure(ClassA("test")) +{'a_string': 'test'} # Is this ClassA or ClassB? Who knows! +``` + +What we can do is ensure some extra information is present in the +unstructured data, and then use that information to help structure later. + +First, we register an unstructure hook for the `Union[ClassA, ClassB]` type. + +```python +>>> converter.register_unstructure_hook( +... Union[ClassA, ClassB], +... lambda o: {"_type": type(o).__name__, **converter.unstructure(o)} +... ) +>>> converter.unstructure(ClassA("test"), unstructure_as=Union[ClassA, ClassB]) +{'_type': 'ClassA', 'a_string': 'test'} +``` + +Note that when unstructuring, we had to provide the `unstructure_as` parameter +or _cattrs_ would have just applied the usual unstructuring rules to `ClassA`, +instead of our special union hook. + +Now that the unstructured data contains some information, we can create a +structuring hook to put it to use: + +```python +>>> converter.register_structure_hook( +... Union[ClassA, ClassB], +... lambda o, _: converter.structure(o, ClassA if o["_type"] == "ClassA" else ClassB) +... ) +>>> converter.structure({"_type": "ClassA", "a_string": "test"}, Union[ClassA, ClassB]) +ClassA(a_string='test') +``` diff --git a/docs/unions.rst b/docs/unions.rst deleted file mode 100644 index 16f35546..00000000 --- a/docs/unions.rst +++ /dev/null @@ -1,67 +0,0 @@ -======================== -Tips for handling unions -======================== - -This sections contains information for advanced union handling. - -As mentioned in the structuring section, ``cattrs`` is able to handle simple -unions of ``attrs`` classes automatically. More complex cases require -converter customization (since there are many ways of handling unions). - -Unstructuring unions with extra metadata -**************************************** - -Let's assume a simple scenario of two classes, ``ClassA`` and ``ClassB``, both -of which have no distinct fields and so cannot be used automatically with -``cattrs``. - -.. code-block:: python - - @define - class ClassA: - a_string: str - - @define - class ClassB: - a_string: str - -A naive approach to unstructuring either of these would yield identical -dictionaries, and not enough information to restructure the classes. - -.. code-block:: python - - >>> converter.unstructure(ClassA("test")) - {'a_string': 'test'} # Is this ClassA or ClassB? Who knows! - -What we can do is ensure some extra information is present in the -unstructured data, and then use that information to help structure later. - -First, we register an unstructure hook for the `Union[ClassA, ClassB]` type. - -.. code-block:: python - - >>> converter.register_unstructure_hook( - ... Union[ClassA, ClassB], - ... lambda o: {"_type": type(o).__name__, **converter.unstructure(o)} - ... ) - >>> converter.unstructure(ClassA("test"), unstructure_as=Union[ClassA, ClassB]) - {'_type': 'ClassA', 'a_string': 'test'} - -Note that when unstructuring, we had to provide the `unstructure_as` parameter -or `cattrs` would have just applied the usual unstructuring rules to `ClassA`, -instead of our special union hook. - -Now that the unstructured data contains some information, we can create a -structuring hook to put it to use: - -.. code-block:: python - - >>> converter.register_structure_hook( - ... Union[ClassA, ClassB], - ... lambda o, _: converter.structure(o, ClassA if o["_type"] == "ClassA" else ClassB) - ... ) - >>> converter.structure({"_type": "ClassA", "a_string": "test"}, Union[ClassA, ClassB]) - ClassA(a_string='test') - -In the future, `cattrs` will gain additional tools to make union handling even -easier and automate generating these hooks. diff --git a/poetry.lock b/poetry.lock index e5bb00f4..eabbb1d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -297,6 +297,28 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "markdown-it-py" +version = "2.1.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +mdurl = ">=0.1,<1.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] +code_style = ["pre-commit (==2.6)"] +compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.3.6,<3.4.0)", "mistletoe (>=0.8.1,<0.9.0)", "mistune (>=2.0.2,<2.1.0)", "panflute (>=2.1.3,<2.2.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "MarkupSafe" version = "2.1.1" @@ -313,6 +335,30 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "mdit-py-plugins" +version = "0.3.1" +description = "Collection of plugins for markdown-it-py" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +markdown-it-py = ">=1.0.0,<3.0.0" + +[package.extras] +code_style = ["pre-commit"] +rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "msgpack" version = "1.0.4" @@ -329,6 +375,29 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "myst-parser" +version = "0.18.1" +description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +docutils = ">=0.15,<0.20" +jinja2 = "*" +markdown-it-py = ">=1.0.0,<3.0.0" +mdit-py-plugins = ">=0.3.1,<0.4.0" +pyyaml = "*" +sphinx = ">=4,<6" +typing-extensions = "*" + +[package.extras] +code_style = ["pre-commit (>=2.12,<3.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +rtd = ["ipython", "sphinx-book-theme", "sphinx-design", "sphinxcontrib.mermaid (>=0.7.1,<0.8.0)", "sphinxext-opengraph (>=0.6.3,<0.7.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx (<5.2)", "sphinx-pytest"] + [[package]] name = "orjson" version = "3.8.0" @@ -635,6 +704,21 @@ sphinx = ">=4.0,<6.0" [package.extras] docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] +[[package]] +name = "sphinx-copybutton" +version = "0.5.0" +description = "Add a copy button to each of your code cells." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +sphinx = ">=1.8" + +[package.extras] +code_style = ["pre-commit (==2.12.1)"] +rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme"] + [[package]] name = "sphinxcontrib-applehelp" version = "1.0.2" @@ -815,7 +899,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = ">= 3.7" -content-hash = "a8b510769f49b8cf48b94570d79ff936e704a0a34d119fd8ca69a51fcf01fa3b" +content-hash = "4fad3ab400a4e9b12f9ff833774355ba5dcbd77cea56902383952c2f9f5dd7f8" [metadata.files] alabaster = [ @@ -1030,6 +1114,10 @@ Jinja2 = [ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] +markdown-it-py = [ + {file = "markdown-it-py-2.1.0.tar.gz", hash = "sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"}, + {file = "markdown_it_py-2.1.0-py3-none-any.whl", hash = "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27"}, +] MarkupSafe = [ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, @@ -1076,6 +1164,14 @@ mccabe = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +mdit-py-plugins = [ + {file = "mdit-py-plugins-0.3.1.tar.gz", hash = "sha256:3fc13298497d6e04fe96efdd41281bfe7622152f9caa1815ea99b5c893de9441"}, + {file = "mdit_py_plugins-0.3.1-py3-none-any.whl", hash = "sha256:606a7f29cf56dbdfaf914acb21709b8f8ee29d857e8f29dcc33d8cb84c57bfa1"}, +] +mdurl = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] msgpack = [ {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"}, @@ -1134,6 +1230,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +myst-parser = [ + {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, + {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, +] orjson = [ {file = "orjson-3.8.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:9a93850a1bdc300177b111b4b35b35299f046148ba23020f91d6efd7bf6b9d20"}, {file = "orjson-3.8.0-cp310-cp310-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:7536a2a0b41672f824912aeab545c2467a9ff5ca73a066ff04fb81043a0a177a"}, @@ -1392,6 +1492,10 @@ sphinx-basic-ng = [ {file = "sphinx_basic_ng-0.0.1a12-py3-none-any.whl", hash = "sha256:e8b6efd2c5ece014156de76065eda01ddfca0fee465aa020b1e3c12f84570bbe"}, {file = "sphinx_basic_ng-0.0.1a12.tar.gz", hash = "sha256:cffffb14914ddd26c94b1330df1d72dab5a42e220aaeb5953076a40b9c50e801"}, ] +sphinx-copybutton = [ + {file = "sphinx-copybutton-0.5.0.tar.gz", hash = "sha256:a0c059daadd03c27ba750da534a92a63e7a36a7736dcf684f26ee346199787f6"}, + {file = "sphinx_copybutton-0.5.0-py3-none-any.whl", hash = "sha256:9684dec7434bd73f0eea58dda93f9bb879d24bff2d8b187b1f2ec08dfe7b5f48"}, +] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, diff --git a/pyproject.toml b/pyproject.toml index d579109d..bb397347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ tomlkit = { version = "^0.11.4", python = "<4" } furo = "^2022.9.29" coverage = "^6.2" urllib3 = { version = "^1.26.12", python = "<4" } +sphinx-copybutton = "^0.5.0" +myst-parser = "^0.18.1" [tool.poetry.urls] "Changelog" = "https://cattrs.readthedocs.io/en/latest/history.html" diff --git a/src/cattr/preconf/ujson.py b/src/cattr/preconf/ujson.py index fd008bc2..e85c2ff5 100644 --- a/src/cattr/preconf/ujson.py +++ b/src/cattr/preconf/ujson.py @@ -1,4 +1,4 @@ """Preconfigured converters for ujson.""" -from cattrs.preconf.ujson import configure_converter, make_converter, UjsonConverter +from cattrs.preconf.ujson import UjsonConverter, configure_converter, make_converter -__all__ = ["configure_converter", "make_converter", UjsonConverter] +__all__ = ["configure_converter", "make_converter", "UjsonConverter"] diff --git a/src/cattrs/strategies/__init__.py b/src/cattrs/strategies/__init__.py new file mode 100644 index 00000000..af95dd96 --- /dev/null +++ b/src/cattrs/strategies/__init__.py @@ -0,0 +1,4 @@ +"""High level strategies for converters.""" +from ._unions import configure_tagged_union + +__all__ = ["configure_tagged_union"] diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py new file mode 100644 index 00000000..64212aac --- /dev/null +++ b/src/cattrs/strategies/_unions.py @@ -0,0 +1,94 @@ +from collections import defaultdict +from typing import Any, Callable, Dict, Optional, Type + +from attrs import NOTHING + +from cattrs import Converter + +__all__ = [] + + +def default_tag_generator(typ: Type) -> str: + """Return the class name.""" + return typ.__name__ + + +def configure_tagged_union( + union: Any, + converter: Converter, + tag_generator: Callable[[Type], str] = default_tag_generator, + tag_name: str = "_type", + default: Optional[Type] = NOTHING, +) -> None: + """ + Configure the converter so that `union` (which should be a union) is + un/structured with the help of an additional piece of data in the + unstructured payload, the tag. + + :param converter: The converter to apply the strategy to. + :param tag_generator: A `tag_generator` function is used to map each + member of the union to a tag, which is then included in the + unstructured payload. The default tag generator returns the name of + the class. + :param tag_name: The key under which the tag will be set in the + unstructured payload. By default, `'_type'`. + :param default: An optional class to be used if the tag information + is not present when structuring. + + The tagged union strategy currently only works with the dict + un/structuring base strategy. + + .. versionadded:: 22.3.0 + """ + args = union.__args__ + tag_to_hook = {} + for cl in args: + tag = tag_generator(cl) + handler = converter._structure_func.dispatch(cl) + + def structure_union_member(val: dict, _cl=cl, _h=handler) -> cl: + return _h(val, _cl) + + tag_to_hook[tag] = structure_union_member + cl_to_tag = {cl: tag_generator(cl) for cl in args} + + if default is not NOTHING: + default_handler = converter._structure_func.dispatch(default) + + def structure_default(val: dict, _cl=default, _h=default_handler): + return _h(val, _cl) + + tag_to_hook = defaultdict(lambda: structure_default, tag_to_hook) + cl_to_tag = defaultdict(lambda: default, cl_to_tag) + + def unstructure_tagged_union( + val: union, _c=converter, _cl_to_tag=cl_to_tag, _tag_name=tag_name + ) -> Dict: + res = _c.unstructure(val) + res[_tag_name] = _cl_to_tag[val.__class__] + return res + + if default is NOTHING: + + def structure_tagged_union( + val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name + ) -> union: + return _tag_to_cl[val[_tag_name]](val) + + else: + + def structure_tagged_union( + val: dict, + _, + _tag_to_hook=tag_to_hook, + _tag_name=tag_name, + _dh=default_handler, + _default=default, + ) -> union: + if _tag_name in val: + return _tag_to_hook[val[_tag_name]](val) + else: + return _dh(val, _default) + + converter.register_unstructure_hook(union, unstructure_tagged_union) + converter.register_structure_hook(union, structure_tagged_union) diff --git a/tests/strategies/__init__.py b/tests/strategies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/strategies/test_tagged_unions.py b/tests/strategies/test_tagged_unions.py new file mode 100644 index 00000000..d5ee7cb9 --- /dev/null +++ b/tests/strategies/test_tagged_unions.py @@ -0,0 +1,104 @@ +from typing import Union + +from attrs import define + +from cattrs import BaseConverter +from cattrs.strategies import configure_tagged_union + + +@define +class A: + a: int + + +@define +class B: + a: str + + +def test_defaults(converter: BaseConverter) -> None: + """Defaults should work.""" + union = Union[A, B] + configure_tagged_union(union, converter) + + assert converter.unstructure(A(1), union) == {"_type": "A", "a": 1} + assert converter.unstructure(B("1"), union) == {"_type": "B", "a": "1"} + + assert converter.structure({"_type": "A", "a": 1}, union) == A(1) + assert converter.structure({"_type": "B", "a": 1}, union) == B("1") + + +def test_tag_name(converter: BaseConverter) -> None: + """Tag names are customizable.""" + union = Union[A, B] + tag_name = "t" + configure_tagged_union(union, converter, tag_name=tag_name) + + assert converter.unstructure(A(1), union) == {tag_name: "A", "a": 1} + assert converter.unstructure(B("1"), union) == {tag_name: "B", "a": "1"} + + assert converter.structure({tag_name: "A", "a": 1}, union) == A(1) + assert converter.structure({tag_name: "B", "a": 1}, union) == B("1") + + +def test_tag_generator(converter: BaseConverter) -> None: + """Tag values are customizable using a callable.""" + union = Union[A, B] + configure_tagged_union( + union, converter, tag_generator=lambda t: f"{t.__module__}.{t.__name__}" + ) + + assert converter.unstructure(A(1), union) == { + "_type": "tests.strategies.test_tagged_unions.A", + "a": 1, + } + assert converter.unstructure(B("1"), union) == { + "_type": "tests.strategies.test_tagged_unions.B", + "a": "1", + } + + assert converter.structure( + {"_type": "tests.strategies.test_tagged_unions.A", "a": 1}, union + ) == A(1) + assert converter.structure( + {"_type": "tests.strategies.test_tagged_unions.B", "a": 1}, union + ) == B("1") + + +def test_tag_generator_dict(converter: BaseConverter) -> None: + """Tag values are customizable using a dict.""" + union = Union[A, B] + configure_tagged_union( + union, + converter, + tag_generator={cl: f"type:{cl.__name__}" for cl in union.__args__}.get, + ) + + assert converter.unstructure(A(1), union) == {"_type": "type:A", "a": 1} + assert converter.unstructure(B("1"), union) == {"_type": "type:B", "a": "1"} + + assert converter.structure({"_type": "type:A", "a": 1}, union) == A(1) + assert converter.structure({"_type": "type:B", "a": 1}, union) == B("1") + + +def test_default_member(converter: BaseConverter) -> None: + """Tagged unions can have default members.""" + union = Union[A, B] + configure_tagged_union(union, converter, default=A) + assert converter.unstructure(A(1), union) == {"_type": "A", "a": 1} + assert converter.unstructure(B("1"), union) == {"_type": "B", "a": "1"} + + # No tag, so should structure as A. + assert converter.structure({"a": 1}, union) == A(1) + + assert converter.structure({"_type": "A", "a": 1}, union) == A(1) + assert converter.structure({"_type": "B", "a": 1}, union) == B("1") + + +def test_default_member_validation(converter: BaseConverter) -> None: + """Default members are structured properly..""" + union = Union[A, B] + configure_tagged_union(union, converter, default=A) + + # A.a should be coerced to an int. + assert converter.structure({"_type": "A", "a": "1"}, union) == A(1)