Skip to content

Commit 6f2953c

Browse files
committed
Functional tests
1 parent ee5ed6e commit 6f2953c

File tree

6 files changed

+143
-26
lines changed

6 files changed

+143
-26
lines changed

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# XML dataclasses
22

3-
This is a very rough prototype of how a library might look like for (de)serialising XML into dataclasses. XML dataclasses build on normal dataclasses from the standard library and [`lxml`](https://pypi.org/project/lxml/) elements. Loading and saving these elements is left to the consumer for flexibility of the desired output.
3+
This is a very rough prototype of how a library might look like for (de)serialising XML into Python dataclasses. XML dataclasses build on normal dataclasses from the standard library and [`lxml`](https://pypi.org/project/lxml/) elements. Loading and saving these elements is left to the consumer for flexibility of the desired output.
44

55
It isn't ready for production if you aren't willing to do your own evaluation/quality assurance. I don't recommend using this library with untrusted content. It inherits all of `lxml`'s flaws with regards to XML attacks, and recursively resolves data structures. Because deserialisation is driven from the dataclass definitions, it shouldn't be possible to execute arbitrary Python code. But denial of service attacks would very likely be feasible.
66

7+
Requires Python 3.7 or higher.
8+
79
## Example
810

911
(This is a simplified real world example - the container can also include optional `links` child elements.)
@@ -42,14 +44,16 @@ class Container:
4244
__ns__ = CONTAINER_NS
4345
version: str = attr()
4446
rootfiles: RootFiles = child()
47+
# WARNING: this is an incomplete implementation of an OPF container
48+
# (it's missing links)
4549

4650

4751
if __name__ == "__main__":
4852
nsmap = {None: CONTAINER_NS}
4953
lxml_el_in = etree.parse("container.xml").getroot()
5054
container = load(Container, lxml_el_in, "container")
5155
lxml_el_out = dump(container, "container", nsmap)
52-
print(etree.tounicode(lxml_el_out, pretty_print=True))
56+
print(etree.tostring(lxml_el_out, encoding="unicode", pretty_print=True))
5357
```
5458

5559
## Features
@@ -75,3 +79,35 @@ Most of these limitations/assumptions are enforced. They may make this project u
7579
* Deserialisation is strict; missing required attributes and child elements will cause an error
7680
* Unions of types aren't yet supported
7781
* Dataclasses must be written by hand, no tools are provided to generate these from, DTDs, XML schema definitions, or RELAX NG schemas
82+
83+
## Development
84+
85+
This project uses [pre-commit](https://pre-commit.com/) to run some linting hooks when committing. When you first clone the repo, please run:
86+
87+
```
88+
pre-commit install
89+
```
90+
91+
You may also run the hooks at any time:
92+
93+
```
94+
pre-commit run --all-files
95+
```
96+
97+
Dependencies are managed via [poetry](https://python-poetry.org/). To install all dependencies, use:
98+
99+
```
100+
poetry install
101+
```
102+
103+
This will also install development dependencies such as `black`, `isort`, `pylint`, `mypy`, and `pytest`. I've provided a simple script to run these during development called `lint`. You can either run it from a shell session with the poetry-installed virtual environment, or run as follows:
104+
105+
```
106+
poetry run ./lint
107+
```
108+
109+
Auto-formatters will be applied, and static analysis/tests are run in order. The script stops on failure to allow quick iteration.
110+
111+
## License
112+
113+
This library is licensed under the Mozilla Public License Version 2.0. For more information, see `LICENSE`.

functional/container.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0"?>
2+
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
3+
<rootfiles>
4+
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
5+
</rootfiles>
6+
</container>

functional/container_test.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from pathlib import Path
2+
from typing import List
3+
4+
from lxml import etree
5+
6+
from xml_dataclasses import attr, child, dump, load, xml_dataclass
7+
8+
BASE = Path(__file__).resolve(strict=True).parent
9+
10+
CONTAINER_NS = "urn:oasis:names:tc:opendocument:xmlns:container"
11+
12+
13+
@xml_dataclass
14+
class RootFile:
15+
__ns__ = CONTAINER_NS
16+
full_path: str = attr(rename="full-path")
17+
media_type: str = attr(rename="media-type")
18+
19+
20+
@xml_dataclass
21+
class RootFiles:
22+
__ns__ = CONTAINER_NS
23+
rootfile: List[RootFile] = child()
24+
25+
26+
@xml_dataclass
27+
class Container:
28+
__ns__ = CONTAINER_NS
29+
version: str = attr()
30+
rootfiles: RootFiles = child()
31+
# WARNING: this is an incomplete implementation of an OPF container
32+
# (it's missing links)
33+
34+
35+
def lmxl_dump(el):
36+
encoded = etree.tostring(
37+
el, encoding="utf-8", pretty_print=True, xml_declaration=True
38+
)
39+
return encoded.decode("utf-8")
40+
41+
42+
def test_functional_container():
43+
el = etree.parse(str(BASE / "container.xml")).getroot()
44+
original = lmxl_dump(el)
45+
container = load(Container, el, "container")
46+
assert container == Container(
47+
version="1.0",
48+
rootfiles=RootFiles(
49+
rootfile=[
50+
RootFile(
51+
full_path="OEBPS/content.opf",
52+
media_type="application/oebps-package+xml",
53+
),
54+
],
55+
),
56+
)
57+
el = dump(container, "container", {None: CONTAINER_NS})
58+
roundtrip = lmxl_dump(el)
59+
assert original == roundtrip

lint

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
#!/bin/sh
22
set -ex
3-
isort --recursive src/ tests/
4-
black src/ tests/
3+
4+
if [ -n "$1" ]; then
5+
ISORT_CHECK="--check-only"
6+
BLACK_CHECK="--check"
7+
PYTEST_DEBUG=""
8+
else
9+
ISORT_CHECK=""
10+
BLACK_CHECK=""
11+
PYTEST_DEBUG="-s --pdb --pdbcls=IPython.terminal.debugger:Pdb"
12+
fi
13+
14+
isort --recursive $ISORT_CHECK src/ tests/ functional/
15+
black $BLACK_CHECK src/ tests/ functional/
516
mypy src/xml_dataclasses/ --strict
617
pylint src/
7-
8-
set +e
9-
pytest tests/ --cov=xml_dataclasses -s --pdb --pdbcls=IPython.terminal.debugger:Pdb
10-
coverage html
18+
# always output coverage report
19+
if pytest tests/ --cov=xml_dataclasses --random-order $PYTEST_DEBUG; then
20+
coverage html
21+
else
22+
coverage html
23+
exit 1
24+
fi
25+
pytest functional/ --random-order $PYTEST_DEBUG

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "xml_dataclasses"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
description = "(De)serialize XML documents into specially-annotated dataclasses"
55
authors = ["Toby Fleming <tobywf@users.noreply.github.com>"]
66
license = "MPL-2.0"
@@ -31,6 +31,7 @@ pytest-cov = "^2.8.1"
3131
mypy = "^0.761"
3232
ipython = "^7.12.0"
3333
coverage = {extras = ["toml"], version = "^5.0.3"}
34+
pytest-random-order = "^1.0.4"
3435

3536
[tool.isort]
3637
# see https://black.readthedocs.io/en/stable/the_black_code_style.html

tests/dump_test.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class Foo:
5656

5757
foo = Foo(bar="baz")
5858
el = dump(foo, "foo", None)
59-
xml = etree.tounicode(el)
59+
xml = etree.tostring(el, encoding="unicode")
6060
assert xml == '<foo bar="baz"/>'
6161

6262

@@ -68,7 +68,7 @@ class Foo:
6868

6969
foo = Foo(bar="baz")
7070
el = dump(foo, "foo", None)
71-
xml = etree.tounicode(el)
71+
xml = etree.tostring(el, encoding="unicode")
7272
assert xml == '<foo bar="baz"/>'
7373

7474

@@ -80,7 +80,7 @@ class Foo:
8080

8181
foo = Foo()
8282
el = dump(foo, "foo", None)
83-
xml = etree.tounicode(el)
83+
xml = etree.tostring(el, encoding="unicode")
8484
assert xml == "<foo/>"
8585

8686

@@ -92,7 +92,7 @@ class Foo:
9292

9393
foo = Foo()
9494
el = dump(foo, "foo", None)
95-
xml = etree.tounicode(el)
95+
xml = etree.tostring(el, encoding="unicode")
9696
assert xml == '<foo bar="baz"/>'
9797

9898

@@ -104,7 +104,7 @@ class Foo:
104104

105105
foo = Foo(value="bar")
106106
el = dump(foo, "foo", None)
107-
xml = etree.tounicode(el)
107+
xml = etree.tostring(el, encoding="unicode")
108108
assert xml == "<foo>bar</foo>"
109109

110110

@@ -116,7 +116,7 @@ class Foo:
116116

117117
foo = Foo(value="bar")
118118
el = dump(foo, "foo", None)
119-
xml = etree.tounicode(el)
119+
xml = etree.tostring(el, encoding="unicode")
120120
assert xml == "<foo>bar</foo>"
121121

122122

@@ -128,7 +128,7 @@ class Foo:
128128

129129
foo = Foo()
130130
el = dump(foo, "foo", None)
131-
xml = etree.tounicode(el)
131+
xml = etree.tostring(el, encoding="unicode")
132132
assert xml == "<foo/>"
133133

134134

@@ -140,7 +140,7 @@ class Foo:
140140

141141
foo = Foo()
142142
el = dump(foo, "foo", None)
143-
xml = etree.tounicode(el)
143+
xml = etree.tostring(el, encoding="unicode")
144144
assert xml == "<foo>bar</foo>"
145145

146146

@@ -152,7 +152,7 @@ class Foo:
152152

153153
foo = Foo(bar=Child())
154154
el = dump(foo, "foo", None)
155-
xml = etree.tounicode(el)
155+
xml = etree.tostring(el, encoding="unicode")
156156
assert xml == "<foo><bar/></foo>"
157157

158158

@@ -164,7 +164,7 @@ class Foo:
164164

165165
foo = Foo(bar=Child())
166166
el = dump(foo, "foo", None)
167-
xml = etree.tounicode(el)
167+
xml = etree.tostring(el, encoding="unicode")
168168
assert xml == "<foo><bar/></foo>"
169169

170170

@@ -176,7 +176,7 @@ class Foo:
176176

177177
foo = Foo()
178178
el = dump(foo, "foo", None)
179-
xml = etree.tounicode(el)
179+
xml = etree.tostring(el, encoding="unicode")
180180
assert xml == "<foo/>"
181181

182182

@@ -188,7 +188,7 @@ class Foo:
188188

189189
foo = Foo()
190190
el = dump(foo, "foo", None)
191-
xml = etree.tounicode(el)
191+
xml = etree.tostring(el, encoding="unicode")
192192
assert xml == "<foo><bar/></foo>"
193193

194194

@@ -200,7 +200,7 @@ class Foo:
200200

201201
foo = Foo(bar=[Child()])
202202
el = dump(foo, "foo", None)
203-
xml = etree.tounicode(el)
203+
xml = etree.tostring(el, encoding="unicode")
204204
assert xml == "<foo><bar/></foo>"
205205

206206

@@ -212,7 +212,7 @@ class Foo:
212212

213213
foo = Foo(bar=[Child(), Child()])
214214
el = dump(foo, "foo", None)
215-
xml = etree.tounicode(el)
215+
xml = etree.tostring(el, encoding="unicode")
216216
assert xml == "<foo><bar/><bar/></foo>"
217217

218218

@@ -224,7 +224,7 @@ class Foo:
224224

225225
foo = Foo(bar=[Child(), Child()])
226226
el = dump(foo, "foo", None)
227-
xml = etree.tounicode(el)
227+
xml = etree.tostring(el, encoding="unicode")
228228
assert xml == "<foo><bar/><bar/></foo>"
229229

230230

@@ -236,7 +236,7 @@ class Foo:
236236

237237
foo = Foo()
238238
el = dump(foo, "foo", None)
239-
xml = etree.tounicode(el)
239+
xml = etree.tostring(el, encoding="unicode")
240240
assert xml == "<foo/>"
241241

242242

@@ -249,6 +249,6 @@ def test_dump_children_multiple_missing_default():
249249

250250
# foo = Foo()
251251
# el = dump(foo, "foo", None)
252-
# xml = etree.tounicode(el)
252+
# xml = etree.tostring(el, encoding="unicode")
253253
# assert xml == "<foo><bar/><bar/></foo>"
254254
pass

0 commit comments

Comments
 (0)