xml-pipeline/third_party/xmlable/_xmlify.py
2026-01-08 15:35:36 -08:00

156 lines
5.5 KiB
Python

"""XMLable
A decorator to allow creation of xml config based on python dataclasses
Given a dataclass:
- Produce an xsd schema based on the class
- Produce an xml template based on the class
- Given any instance of the class, make a best-effort attempt at turning it into
a filled xml
- Create a parser for parsing the xml
"""
from humps import pascalize
from dataclasses import fields, is_dataclass
from typing import Any, dataclass_transform, cast
from lxml.objectify import ObjectifiedElement
from lxml.etree import Element, _Element
from xmlable._utils import get, typename, AnyType
from xmlable._errors import XError, XErrorCtx, ErrorTypes
from xmlable._manual import manual_xmlify
from xmlable._lxml_helpers import with_children, with_child, XMLSchema
from xmlable._xobject import XObject, gen_xobject
def validate_class(cls: AnyType):
"""
Validate tha the class can be xmlified
- Must be a dataclass
- Cannot have any members called 'comment' (lxml parses comments as this tag)
- Cannot have
"""
if not is_dataclass(cls):
raise ErrorTypes.NotADataclass(cls)
reserved_attrs = ["get_xobject", "xsd_forward", "xsd_dependencies"]
# TODO: cleanup repetition
for f in fields(cls):
if f.name in reserved_attrs:
raise ErrorTypes.ReservedAttribute(cls, f.name)
elif f.name == "comment":
raise ErrorTypes.CommentAttribute(cls)
# JUSTIFY: Could potentially have added other attributes (of the class,
# rather than a field of an instance as provided by dataclass
# fields)
for reserved in reserved_attrs:
if hasattr(cls, reserved):
raise ErrorTypes.ReservedAttribute(cls, reserved)
if hasattr(cls, "comment"):
raise ErrorTypes.CommentAttribute(cls)
@dataclass_transform()
def xmlify(cls: type) -> AnyType:
try:
validate_class(cls)
cls_name = typename(cls)
forward_decs = cast(set[AnyType], {cls})
meta_xobjects = [
(
pascalize(f.name),
f,
gen_xobject(cast(AnyType, f.type), forward_decs),
)
for f in fields(cls)
]
class UserXObject(XObject):
def xsd_out(
self,
name: str,
attribs: dict[str, str] = {},
add_ns: dict[str, str] = {},
) -> _Element:
return Element(
f"{XMLSchema}element",
name=name,
type=cls_name,
attrib=attribs,
)
def xml_temp(self, name: str) -> _Element:
return with_children(
Element(name),
[
xobj.xml_temp(pascal_name)
for pascal_name, _, xobj in meta_xobjects
],
)
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
return with_children(
Element(name),
[
xobj.xml_out(
pascal_name,
get(val, m.name),
ctx.next(pascal_name),
)
for pascal_name, m, xobj in meta_xobjects
],
)
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
parsed: dict[str, Any] = {}
for pascal_name, m, xobj in meta_xobjects:
if (m_obj := get(obj, pascal_name)) is not None:
parsed[m.name] = xobj.xml_in(
m_obj, ctx.next(pascal_name)
)
else:
raise ErrorTypes.NonMemberTag(ctx, cls, obj.tag, m.name)
return cls(**parsed)
cls_xobject = UserXObject()
# JUSTIFY: Why are xsd forward & dependencies not part of xobject?
# - xobject covers the use (not forward decs)
# - we want to present error messages to the user containing
# their types, so xsd dependencies are in terms of python
# types, rather than xobjects
# - forward and dependencies do not apply to the basic types,
# only user types
def xsd_forward(add_ns: dict[str, str]) -> _Element:
return with_child(
Element(f"{XMLSchema}complexType", name=cls_name),
with_children(
Element(f"{XMLSchema}sequence"),
[
xobj.xsd_out(pascal_name, attribs={}, add_ns=add_ns)
for pascal_name, m, xobj in meta_xobjects
],
),
)
def xsd_dependencies() -> set[AnyType]:
return forward_decs
def get_xobject():
return cls_xobject
# helper methods for gen_xobject, and other dataclasses to generate their
# x methods
cls.xsd_forward = xsd_forward # type: ignore[attr-defined]
cls.xsd_dependencies = xsd_dependencies # type: ignore[attr-defined]
cls.get_xobject = get_xobject # type: ignore[attr-defined]
return manual_xmlify(cls)
except XError as e:
# NOTE: Trick to remove dirty 'internal' traceback, and raise from
# xmlify (makes more sense to user than seeing internals)
e.__traceback__ = None
raise e