lxml-dataclass
Define XML models with annotated Python classes. Element generates an initializer
and value-based equality, while element_field() configures XML child elements.
Requires Python 3.11+ and lxml 5.4+.
Installation
pip install lxml-dataclass
Basic usage
from lxml_dataclass import Element, element_field
class Author(Element):
__tag__ = "Author"
name: str = element_field("Name")
last: str = element_field("LastName", default="Doe")
def fullname(self) -> str:
return f"{self.name} {self.last}"
author = Author("John")
assert author.fullname() == "John Doe"
assert author.to_string_element() == (
b"<Author><Name>John</Name><LastName>Doe</LastName></Author>"
)
assert Author.from_data(author.to_string_element()) == author
Annotations without element_field() use the Python attribute name as the XML tag:
class Quantity(Element):
__tag__ = "Quantity"
count: int
assert Quantity(0).to_string_element() == b"<Quantity><count>0</count></Quantity>"
__tag__ is required for XML conversion. Ordinary methods can be added to models.
Annotate class configuration with typing.ClassVar if you want type annotations
on attributes such as __tag__ or __nsmap__.
Serialization and parsing
| Method | Behavior |
|---|---|
model.to_lxml_element() |
Creates a new lxml.etree._Element tree. |
model.to_string_element(**kwargs) |
Forwards options to lxml.etree.tostring; returns bytes by default or a string with encoding="unicode". |
Model.from_lxml_element(root, prefix="") |
Checks the root tag and reads child elements into a model. |
Model.from_data(data, prefix="", **kwargs) |
Parses bytes or text with lxml.etree.fromstring, then reads the model. Options such as parser are forwarded to lxml. |
import lxml.etree as ET
root = ET.fromstring(b"<Author><Name>Jane</Name></Author>")
author = Author.from_lxml_element(root)
assert author.last == "Doe"
assert isinstance(author.to_string_element(encoding="unicode"), str)
Parsing converts text using the annotation or an explicit coerce callable.
Supported annotations include scalar constructors such as str, int, float,
Decimal, and UUID; nested Element models; list[T]; homogeneous
tuple[T, ...]; and optional forms such as T | None or Optional[T].
Annotated[T, ...] uses T, and Any reads text as strings.
Boolean parsing accepts true, false, 1, and 0, ignoring case and surrounding
whitespace. Invalid boolean text raises ValueError.
Postponed annotations, self references, and module-level forward references are
supported. A reference must resolve when XML conversion occurs; unresolved local
references can use an explicit coerce. Ambiguous unions, heterogeneous tuples,
and other unsupported generic types require an explicit converter.
Missing and empty values
- A missing XML child preserves its
defaultor calls itsdefault_factory. - A missing required scalar raises
ValueErrornaming the field. Optional annotations still needdefault=Noneif the child may be absent. - A missing collection without a default becomes an empty list or tuple.
- A present empty element reads as
""forstrand asNonefor other scalar converters. A custom converter is called only when text is present. - Serialization preserves
0andFalse.Noneand empty strings are omitted unlessdisplay_empty=True; empty collections always emit no children.
Unknown XML children are ignored. Repeated scalar tags use the first matching child. Models represent the configured children, rather than preserving arbitrary XML, comments, mixed content, or unmodeled child attributes.
Nested models and collections
class Book(Element):
__tag__ = "Book"
title: str = element_field("Title")
pages: int = element_field("Pages", default=50)
class Library(Element):
__tag__ = "Library"
books: list[Book] = element_field("books", default_factory=list)
featured: Book | None = element_field("Featured", default=None)
library = Library([Book("First"), Book("Second", 80)], featured=Book("Special"))
root = library.to_lxml_element()
assert [child.tag for child in root] == ["Book", "Book", "Featured"]
assert Library.from_data(library.to_string_element()) == library
For a nested model, a field tag equal to its Python attribute name uses the nested
model's __tag__. A different tag explicitly overrides the nested root tag.
Field attributes are applied to the nested root. Lists and tuples produce repeated
siblings without a wrapper element; collection behavior is inferred automatically.
Use default_factory=list for independent empty lists on each instance.
Inheritance and initialization
Base fields precede subclass fields, and subclass declarations override inherited fields in place. Required positional fields cannot follow fields with defaults, including inherited defaults. Use keyword-only fields for mixin defaults:
from uuid import UUID, uuid4
class HasIDMixin(Element):
id: UUID = element_field("Id", default_factory=uuid4, kw_only=True)
class IdentifiedAuthor(HasIDMixin):
__tag__ = "Author"
name: str = element_field("Name")
author = IdentifiedAuthor("John")
assert isinstance(author.id, UUID)
class Model(Element, kw_only=True) makes its new fields keyword-only; an individual
field can override this with kw_only=False. Inherited fields retain their settings.
init=False removes a field from the constructor. Its default or factory is
initialized normally, and parsed XML is assigned after construction. ClassVar
attributes are excluded from initialization, equality, and XML conversion.
dataclasses.InitVar parameters are passed to __post_init__ and excluded from
XML; give them defaults if the generated constructor should work during parsing.
Models support generated __init__, __eq__, and __post_init__, but are not
standard dataclass instances: utilities such as dataclasses.asdict() and
dataclasses.replace() do not apply. Generated value equality makes models
unhashable unless an explicit __hash__ is supplied. Custom initializers and equality
methods are preserved; a custom initializer must accept the fields supplied by parsing.
Attributes and namespaces
__attrib__ and __nsmap__ configure the root; attrib and nsmap on
element_field() configure children. Assign a new instance dictionary when changing
root attributes, so a class dictionary is not shared accidentally:
author = Author("John")
author.__attrib__ = {"id": "123"}
assert author.to_lxml_element().get("id") == "123"
Parsed root attributes and namespace dictionaries are copied from the input tree.
Use Clark notation ({namespace-uri}local-name) for explicit namespaced tags:
class NamespacedAuthor(Element):
__tag__ = "{urn:books}Author"
__nsmap__ = {"books": "urn:books"}
name: str = element_field("{urn:books}Name")
model = NamespacedAuthor("John")
assert NamespacedAuthor.from_data(model.to_string_element()) == model
Prefixed tags such as books:Name resolve through the namespace map. A default
namespace ({None: "urn:books"}) qualifies unqualified root and child tags,
including nested models. Field namespace maps extend or override inherited maps.
Declaring a named prefix alone does not qualify an unqualified tag.
For parsing existing XML with unqualified model definitions, prefix="{urn:books}"
or prefix="books:" selects namespaced tags. The named prefix must exist in the
input XML or field namespace map. For consistent namespaced output, define the
namespace in your model tags or use a default namespace.
Field options
| Option | Purpose |
|---|---|
tag |
Required child tag; see nested model tag rules above. |
attrib, nsmap |
XML attributes and namespace declarations. |
default, default_factory |
A default value or zero-argument factory; mutually exclusive. Mutable defaults require a factory. |
display_empty=False |
Emit empty scalar elements for None or empty strings. |
format_spec=None |
Python format specification for serialization, such as .2f or %Y-%m-%d for a datetime. |
coerce=None |
Convert XML text during parsing; defaults to the annotation's constructor. |
is_iterable=None |
Infer repeated children from the annotation; True or False overrides inference. |
validators=None |
Callables receiving (field, value) during initialization and assignment; raise an exception to reject a value. |
compare=True |
Include the field in generated equality. |
init=True |
Include the field in the generated constructor. |
kw_only |
Override the class keyword-only setting for this field. |
Formatting and coercion are independent: a datetime formatting specification does
not provide a datetime parser. Supply a converter such as datetime.fromisoformat.
Validators check assignment, including parsed values, but do not intercept in-place
mutations such as model.books.append(...). Type annotations configure conversion;
they do not enforce Python value types during assignment.
Set __ignored_fields__ = ["field_name"] to exclude fields from serialization and
parsing while retaining constructor behavior, validators, and comparison settings.
Ignored fields need defaults or a compatible custom initializer for parsing.
Development
uv sync
uv run pytest
uv run mypy src/lxml_dataclass
Alternatively:
python -m pip install -e . pytest mypy lxml-stubs build
python -m pytest
python -m mypy src/lxml_dataclass
python -m build
The uv configuration builds lxml from source. If your machine lacks the required compiler or libraries, use the pip alternative with a compatible lxml wheel.
CI builds the package and tests the installed wheel on Python 3.11–3.14, including lxml 5.4.0 on Python 3.11. Releases run these checks before publishing.
Publishing with uv
Version 2.0.0 is a stable release with the compatibility changes listed below. Run tests before building a release:
uv sync --locked
uv run pytest
uv run mypy src/lxml_dataclass
uv build --no-sources --out-dir dist/2.0.0
uv publish --dry-run dist/2.0.0/*
To upload the verified artifacts to PyPI, set UV_PUBLISH_TOKEN to your PyPI API
token in your environment, then run:
uv publish dist/2.0.0/*
Using a version-specific output directory keeps previous releases out of the upload.
Never commit your token. The existing GitHub release workflow can also publish
using the repository's PYPI_API_TOKEN secret after its checks pass.
Compatibility notes
These fixes change some previous behaviors: false scalar values are preserved, missing children retain defaults, subclass fields override base fields, and inherited fields use dataclass ordering. Explicit nested tag overrides are now honored, and both parsing entry points validate the root tag. Default namespaces now qualify actual lxml tags as well as serialized XML.
Metadata
Release files for lxml-dataclass 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lxml_dataclass-2.0.0.tar.gz | 24.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lxml_dataclass-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 38.8 kB
Release files / lxml_dataclass-2.0.0.tar.gz
| Download URL | lxml_dataclass-2.0.0.tar.gz |
|---|---|
| Size | 24.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
087b13b8c227196968563da6e2e97030e865ae5ac7bb9b02e238e8e6269bf1d7
|
|
BLAKE2b-256 checksum How to use checksums |
0cca6234b43845719277d8b7a1b4ef7e946fe4fc58f6d034a8c80f6f43367dc5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / lxml_dataclass-2.0.0-py3-none-any.whl
| Download URL | lxml_dataclass-2.0.0-py3-none-any.whl |
|---|---|
| Size | 14.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d9dcd7e2e9631652240bff5203408d71bb6ddea425f97f121119079cb0053afd
|
|
BLAKE2b-256 checksum How to use checksums |
6dcd369003fa520a3bf7073f671dc012a48182a8f13e1b1d2baf4ebeda3c4d1b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|