Skip to content

Commit

Permalink
Non-XML dataclass/ignored fields (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
tobywf authored Mar 26, 2020
1 parent 45f42b1 commit 9d78886
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 6 deletions.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion functional/container_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "MPL-2.0"
Expand Down
3 changes: 2 additions & 1 deletion src/xml_dataclasses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,4 +23,5 @@
"xml_dataclass",
"NsMap",
"XmlDataclass",
"ignored",
]
7 changes: 7 additions & 0 deletions src/xml_dataclasses/modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/xml_dataclasses/resolve_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion tests/modifiers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
19 changes: 18 additions & 1 deletion tests/resolve_types_resolve_test.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

0 comments on commit 9d78886

Please sign in to comment.