diff --git a/README.md b/README.md index 239a2f5..299c33e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Requires Python 3.7 or higher. * Inheritance does work, but has the same limitations as dataclasses. Inheriting from base classes with required fields and declaring optional fields doesn't work due to field order. This isn't recommended * Namespace support is decent as long as correctly declared. I've tried on several real-world examples, although they were known to be valid. `lxml` does a great job at expanding namespace information when loading and simplifying it when saving * Post-load validation hook `xml_validate` +* Fields not required in the constructor are ignored by this library (via `ignored()` or `init=False`) ## Patterns @@ -84,6 +85,14 @@ def xml_validate(self) -> None: If defined, the `load` function will call it after all values have been loaded and assigned to the XML dataclass. You can validate the fields you want inside this method. Return values are ignored; instead raise and catch exceptions. +### Ignored fields + +Fields not required in the constructor are ignored by this library (new in version 0.0.6). This is useful if you want to populate a field via post-load validation. + +You can simply set `init=False`, although you may also want to exclude the field from comparisons. The `ignored` function does this, and can also be used. + +The name doesn't matter, but it might be useful to use the `_` prefix as a convention. + ## Example (fully type hinted) (This is a simplified real world example - the container can also include optional `links` child elements.) @@ -192,10 +201,27 @@ This makes sense in many cases, but possibly not every case. Most of these limitations/assumptions are enforced. They may make this project unsuitable for your use-case. * If you need to pass any parameters to the wrapped `@dataclass` decorator, apply it before the `@xml_dataclass` decorator -* Setting the `init` parameter of a dataclass' `field` will lead to bad things happening, this isn't supported. * Deserialisation is strict; missing required attributes and child elements will cause an error. I want this to be the default behaviour, but it should be straightforward to add a parameter to `load` for lenient operation * Dataclasses must be written by hand, no tools are provided to generate these from, DTDs, XML schema definitions, or RELAX NG schemas +## Changelog + +### [0.0.6] - 2020-03-25 + +* Allow ignored fields via `init=false` or the `ignored` function + +### [0.0.5] - 2020-02-18 + +* Fixed type hinting for consumers. While the library passed mypy validation, it was hard to get XML dataclasses in a codebase to pass mypy validation + +### [0.0.4] - 2020-02-16 + +* Improved type resolving. This lead to easier field definitions, as `attr` and `child` are no longer needed because the type of the field is inferred + +### [0.0.3] - 2020-02-16 + +* Added support for union types on children + ## Development This project uses [pre-commit](https://pre-commit.com/) to run some linting hooks when committing. When you first clone the repo, please run: diff --git a/functional/container_test.py b/functional/container_test.py index 0aecb79..a52841e 100644 --- a/functional/container_test.py +++ b/functional/container_test.py @@ -5,7 +5,15 @@ import pytest # type: ignore from lxml import etree # type: ignore -from xml_dataclasses import NsMap, XmlDataclass, dump, load, rename, xml_dataclass +from xml_dataclasses import ( + NsMap, + XmlDataclass, + dump, + ignored, + load, + rename, + xml_dataclass, +) from .utils import lmxl_dump @@ -36,10 +44,12 @@ class Container(XmlDataclass): version: str rootfiles: RootFiles # WARNING: this is an incomplete implementation of an OPF container + _version: int = ignored() def xml_validate(self) -> None: if self.version != "1.0": raise ValueError(f"Unknown container version '{self.version}'") + self._version = 1 @pytest.mark.parametrize("remove_blank_text", [True, False]) diff --git a/pyproject.toml b/pyproject.toml index b2c8d04..6f30430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xml_dataclasses" -version = "0.0.5" +version = "0.0.6" description = "(De)serialize XML documents into specially-annotated dataclasses" authors = ["Toby Fleming "] license = "MPL-2.0" diff --git a/src/xml_dataclasses/__init__.py b/src/xml_dataclasses/__init__.py index f9d0825..4d9fb41 100644 --- a/src/xml_dataclasses/__init__.py +++ b/src/xml_dataclasses/__init__.py @@ -2,7 +2,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -from .modifiers import rename, text # isort:skip +from .modifiers import rename, text, ignored # isort:skip from .resolve_types import ( # isort:skip is_xml_dataclass, xml_dataclass, @@ -23,4 +23,5 @@ "xml_dataclass", "NsMap", "XmlDataclass", + "ignored", ] diff --git a/src/xml_dataclasses/modifiers.py b/src/xml_dataclasses/modifiers.py index 2ffffb2..fdf8736 100644 --- a/src/xml_dataclasses/modifiers.py +++ b/src/xml_dataclasses/modifiers.py @@ -46,3 +46,10 @@ def text( metadata["xml:text"] = True f.metadata = metadata return f # type: ignore + + +# NOTE: Actual return type is 'Field[_T]', but we want to help type checkers +# to understand the magic that happens at runtime. +# see https://github.com/python/typeshed/blob/master/stdlib/3.7/dataclasses.pyi +def ignored() -> _T: + return field(init=False, compare=False) # type: ignore diff --git a/src/xml_dataclasses/resolve_types.py b/src/xml_dataclasses/resolve_types.py index f0e90a7..601874c 100644 --- a/src/xml_dataclasses/resolve_types.py +++ b/src/xml_dataclasses/resolve_types.py @@ -257,6 +257,10 @@ def xml_dataclass(cls: Type[Any]) -> Type[XmlDataclassInstance]: children: List[ChildInfo] = [] text_field = None for f in fields(cls): + # ignore fields not required in the constructor + if not f.init: + continue + field_info = _resolve_field_type(f) if isinstance(field_info, TextInfo): if text_field is not None: diff --git a/tests/modifiers_test.py b/tests/modifiers_test.py index 7cdea9f..b13b358 100644 --- a/tests/modifiers_test.py +++ b/tests/modifiers_test.py @@ -5,7 +5,7 @@ import pytest -from xml_dataclasses.modifiers import rename, text +from xml_dataclasses.modifiers import ignored, rename, text def dict_comb(items, r=2): @@ -82,3 +82,9 @@ def test_text_has_field_default_ignored(default): assert actual_field is expected_field assert actual_field.default is MISSING assert actual_field.metadata == expected_md + + +def test_ignored_field(): + actual_field = ignored() + assert not actual_field.init + assert not actual_field.compare diff --git a/tests/resolve_types_resolve_test.py b/tests/resolve_types_resolve_test.py index a91a511..6f39c3f 100644 --- a/tests/resolve_types_resolve_test.py +++ b/tests/resolve_types_resolve_test.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field, fields from itertools import product from typing import List, Optional, Union, _GenericAlias @@ -118,3 +118,20 @@ class Foo: # resolve_types_resolve_test.XmlDt1]], NoneType] assert set(bar.base_types) == types assert bar.is_list is is_list + + +def test_non_ctor_field_is_ignored(): + @xml_dataclass + class Foo: + __ns__ = None + bar: str = field(init=False) + + assert not Foo.__attributes__ + assert not Foo.__text_field__ + assert not Foo.__children__ + + dt_fields = fields(Foo) + assert len(dt_fields) == 1 + dt_field = dt_fields[0] + assert dt_field.name == "bar" + assert not dt_field.init