diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/pptx/oxml/xmlchemy.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/pptx/oxml/xmlchemy.py | 717 |
1 files changed, 717 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/pptx/oxml/xmlchemy.py b/.venv/lib/python3.12/site-packages/pptx/oxml/xmlchemy.py new file mode 100644 index 00000000..41fb2e17 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/pptx/oxml/xmlchemy.py @@ -0,0 +1,717 @@ +"""Base and meta classes enabling declarative definition of custom element classes.""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Iterable, Protocol, Sequence, Type, cast + +from lxml import etree +from lxml.etree import ElementBase, _Element # pyright: ignore[reportPrivateUsage] + +from pptx.exc import InvalidXmlError +from pptx.oxml import oxml_parser +from pptx.oxml.ns import NamespacePrefixedTag, _nsmap, qn # pyright: ignore[reportPrivateUsage] +from pptx.util import lazyproperty + + +class AttributeType(Protocol): + """Interface for an object that can act as an attribute type. + + An attribute-type specifies how values are transformed to and from the XML "string" value of the + attribute. + """ + + @classmethod + def from_xml(cls, xml_value: str) -> Any: + """Transform an attribute value to a Python value.""" + ... + + @classmethod + def to_xml(cls, value: Any) -> str: + """Transform a Python value to a str value suitable to this XML attribute.""" + ... + + +def OxmlElement(nsptag_str: str, nsmap: dict[str, str] | None = None) -> BaseOxmlElement: + """Return a "loose" lxml element having the tag specified by `nsptag_str`. + + `nsptag_str` must contain the standard namespace prefix, e.g. 'a:tbl'. The resulting element is + an instance of the custom element class for this tag name if one is defined. + """ + nsptag = NamespacePrefixedTag(nsptag_str) + nsmap = nsmap if nsmap is not None else nsptag.nsmap + return oxml_parser.makeelement(nsptag.clark_name, nsmap=nsmap) + + +def serialize_for_reading(element: ElementBase): + """ + Serialize *element* to human-readable XML suitable for tests. No XML + declaration. + """ + xml = etree.tostring(element, encoding="unicode", pretty_print=True) + return XmlString(xml) + + +class XmlString(str): + """Provides string comparison override suitable for serialized XML; useful for tests.""" + + # ' <w:xyz xmlns:a="http://ns/decl/a" attr_name="val">text</w:xyz>' + # | | || | + # +----------+------------------------------------------++-----------+ + # front attrs | text + # close + + _xml_elm_line_patt = re.compile(r"( *</?[\w:]+)(.*?)(/?>)([^<]*</[\w:]+>)?") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, str): + return False + lines = self.splitlines() + lines_other = other.splitlines() + if len(lines) != len(lines_other): + return False + for line, line_other in zip(lines, lines_other): + if not self._eq_elm_strs(line, line_other): + return False + return True + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def _attr_seq(self, attrs: str) -> list[str]: + """Return a sequence of attribute strings parsed from *attrs*. + + Each attribute string is stripped of whitespace on both ends. + """ + attrs = attrs.strip() + attr_lst = attrs.split() + return sorted(attr_lst) + + def _eq_elm_strs(self, line: str, line_2: str) -> bool: + """True if the element in `line_2` is XML-equivalent to the element in `line`. + + In particular, the order of attributes in XML is not significant. + """ + front, attrs, close, text = self._parse_line(line) + front_2, attrs_2, close_2, text_2 = self._parse_line(line_2) + if front != front_2: + return False + if self._attr_seq(attrs) != self._attr_seq(attrs_2): + return False + if close != close_2: + return False + if text != text_2: + return False + return True + + def _parse_line(self, line: str): + """Return front, attrs, close, text 4-tuple result of parsing XML element string `line`.""" + match = self._xml_elm_line_patt.match(line) + if match is None: + raise ValueError("`line` does not match pattern for an XML element") + front, attrs, close, text = [match.group(n) for n in range(1, 5)] + return front, attrs, close, text + + +class MetaOxmlElement(type): + """Metaclass for BaseOxmlElement.""" + + def __init__(cls, clsname: str, bases: tuple[type, ...], clsdict: dict[str, Any]): + dispatchable = ( + OneAndOnlyOne, + OneOrMore, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, + ZeroOrOneChoice, + ) + for key, value in clsdict.items(): + if isinstance(value, dispatchable): + value.populate_class_members(cls, key) + + +class BaseAttribute: + """Base class for OptionalAttribute and RequiredAttribute, providing common methods.""" + + def __init__(self, attr_name: str, simple_type: type[AttributeType]): + self._attr_name = attr_name + self._simple_type = simple_type + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """ + Add the appropriate methods to *element_cls*. + """ + self._element_cls = element_cls + self._prop_name = prop_name + + self._add_attr_property() + + def _add_attr_property(self): + """Add a read/write `{prop_name}` property to the element class. + + The property returns the interpreted value of this attribute on access and changes the + attribute value to its ST_* counterpart on assignment. + """ + property_ = property(self._getter, self._setter, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + @property + def _clark_name(self): + if ":" in self._attr_name: + return qn(self._attr_name) + return self._attr_name + + @property + def _getter(self) -> Callable[[BaseOxmlElement], Any]: + """Callable suitable for the "get" side of the attribute property descriptor.""" + raise NotImplementedError("must be implemented by each subclass") + + @property + def _setter(self) -> Callable[[BaseOxmlElement, Any], None]: + """Callable suitable for the "set" side of the attribute property descriptor.""" + raise NotImplementedError("must be implemented by each subclass") + + +class OptionalAttribute(BaseAttribute): + """Defines an optional attribute on a custom element class. + + An optional attribute returns a default value when not present for reading. When assigned + |None|, the attribute is removed. + """ + + def __init__(self, attr_name: str, simple_type: type[AttributeType], default: Any = None): + super(OptionalAttribute, self).__init__(attr_name, simple_type) + self._default = default + + @property + def _docstring(self): + """ + Return the string to use as the ``__doc__`` attribute of the property + for this attribute. + """ + return ( + "%s type-converted value of ``%s`` attribute, or |None| (or spec" + "ified default value) if not present. Assigning the default valu" + "e causes the attribute to be removed from the element." + % (self._simple_type.__name__, self._attr_name) + ) + + @property + def _getter(self) -> Callable[[BaseOxmlElement], Any]: + """Callable suitable for the "get" side of the attribute property descriptor.""" + + def get_attr_value(obj: BaseOxmlElement) -> Any: + attr_str_value = obj.get(self._clark_name) + if attr_str_value is None: + return self._default + return self._simple_type.from_xml(attr_str_value) + + get_attr_value.__doc__ = self._docstring + return get_attr_value + + @property + def _setter(self) -> Callable[[BaseOxmlElement, Any], None]: + """Callable suitable for the "set" side of the attribute property descriptor.""" + + def set_attr_value(obj: BaseOxmlElement, value: Any) -> None: + # -- when an XML attribute has a default value, setting it to that default removes the + # -- attribute from the element (when it is present) + if value == self._default: + if self._clark_name in obj.attrib: + del obj.attrib[self._clark_name] + return + str_value = self._simple_type.to_xml(value) + obj.set(self._clark_name, str_value) + + return set_attr_value + + +class RequiredAttribute(BaseAttribute): + """Defines a required attribute on a custom element class. + + A required attribute is assumed to be present for reading, so does not have a default value; + its actual value is always used. If missing on read, an |InvalidXmlError| is raised. It also + does not remove the attribute if |None| is assigned. Assigning |None| raises |TypeError| or + |ValueError|, depending on the simple type of the attribute. + """ + + @property + def _getter(self) -> Callable[[BaseOxmlElement], Any]: + """Callable suitable for the "get" side of the attribute property descriptor.""" + + def get_attr_value(obj: BaseOxmlElement) -> Any: + attr_str_value = obj.get(self._clark_name) + if attr_str_value is None: + raise InvalidXmlError( + "required '%s' attribute not present on element %s" % (self._attr_name, obj.tag) + ) + return self._simple_type.from_xml(attr_str_value) + + get_attr_value.__doc__ = self._docstring + return get_attr_value + + @property + def _docstring(self): + """ + Return the string to use as the ``__doc__`` attribute of the property + for this attribute. + """ + return "%s type-converted value of ``%s`` attribute." % ( + self._simple_type.__name__, + self._attr_name, + ) + + @property + def _setter(self) -> Callable[[BaseOxmlElement, Any], None]: + """Callable suitable for the "set" side of the attribute property descriptor.""" + + def set_attr_value(obj: BaseOxmlElement, value: Any) -> None: + str_value = self._simple_type.to_xml(value) + obj.set(self._clark_name, str_value) + + return set_attr_value + + +class _BaseChildElement: + """Base class for the child element classes corresponding to varying cardinalities. + + Subclasses include ZeroOrOne and ZeroOrMore. + """ + + def __init__(self, nsptagname: str, successors: Sequence[str] = ()): + super(_BaseChildElement, self).__init__() + self._nsptagname = nsptagname + self._successors = successors + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """Baseline behavior for adding the appropriate methods to `element_cls`.""" + self._element_cls = element_cls + self._prop_name = prop_name + + def _add_adder(self): + """Add an ``_add_x()`` method to the element class for this child element.""" + + def _add_child(obj: BaseOxmlElement, **attrs: Any): + new_method = getattr(obj, self._new_method_name) + child = new_method() + for key, value in attrs.items(): + setattr(child, key, value) + insert_method = getattr(obj, self._insert_method_name) + insert_method(child) + return child + + _add_child.__doc__ = ( + "Add a new ``<%s>`` child element unconditionally, inserted in t" + "he correct sequence." % self._nsptagname + ) + self._add_to_class(self._add_method_name, _add_child) + + def _add_creator(self): + """Add a `_new_{prop_name}()` method to the element class. + + This method creates a new, empty element of the correct type, having no attributes. + """ + creator = self._creator + creator.__doc__ = ( + 'Return a "loose", newly created ``<%s>`` element having no attri' + "butes, text, or children." % self._nsptagname + ) + self._add_to_class(self._new_method_name, creator) + + def _add_getter(self): + """Add a read-only `{prop_name}` property to the parent element class. + + The property locates and returns this child element or `None` if not present. + """ + property_ = property(self._getter, None, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + def _add_inserter(self): + """Add an ``_insert_x()`` method to the element class for this child element.""" + + def _insert_child(obj: BaseOxmlElement, child: BaseOxmlElement): + obj.insert_element_before(child, *self._successors) + return child + + _insert_child.__doc__ = ( + "Return the passed ``<%s>`` element after inserting it as a chil" + "d in the correct sequence." % self._nsptagname + ) + self._add_to_class(self._insert_method_name, _insert_child) + + def _add_list_getter(self): + """ + Add a read-only ``{prop_name}_lst`` property to the element class to + retrieve a list of child elements matching this type. + """ + prop_name = f"{self._prop_name}_lst" + property_ = property(self._list_getter, None, None) + setattr(self._element_cls, prop_name, property_) + + @lazyproperty + def _add_method_name(self): + return "_add_%s" % self._prop_name + + def _add_to_class(self, name: str, method: Callable[..., Any]): + """Add `method` to the target class as `name`, unless `name` is already defined there.""" + if hasattr(self._element_cls, name): + return + setattr(self._element_cls, name, method) + + @property + def _creator(self) -> Callable[[BaseOxmlElement], BaseOxmlElement]: + """Callable that creates a new, empty element of the child type, having no attributes.""" + + def new_child_element(obj: BaseOxmlElement): + return OxmlElement(self._nsptagname) + + return new_child_element + + @property + def _getter(self) -> Callable[[BaseOxmlElement], BaseOxmlElement | None]: + """Callable suitable for the "get" side of the property descriptor. + + This default getter returns the child element with matching tag name or |None| if not + present. + """ + + def get_child_element(obj: BaseOxmlElement) -> BaseOxmlElement | None: + return obj.find(qn(self._nsptagname)) + + get_child_element.__doc__ = ( + "``<%s>`` child element or |None| if not present." % self._nsptagname + ) + return get_child_element + + @lazyproperty + def _insert_method_name(self): + return "_insert_%s" % self._prop_name + + @property + def _list_getter(self) -> Callable[[BaseOxmlElement], list[BaseOxmlElement]]: + """Callable suitable for the "get" side of a list property descriptor.""" + + def get_child_element_list(obj: BaseOxmlElement) -> list[BaseOxmlElement]: + return cast("list[BaseOxmlElement]", obj.findall(qn(self._nsptagname))) + + get_child_element_list.__doc__ = ( + "A list containing each of the ``<%s>`` child elements, in the o" + "rder they appear." % self._nsptagname + ) + return get_child_element_list + + @lazyproperty + def _remove_method_name(self): + return "_remove_%s" % self._prop_name + + @lazyproperty + def _new_method_name(self): + return "_new_%s" % self._prop_name + + +class Choice(_BaseChildElement): + """Defines a child element belonging to a group, only one of which may appear as a child.""" + + @property + def nsptagname(self): + return self._nsptagname + + def populate_class_members( # pyright: ignore[reportIncompatibleMethodOverride] + self, element_cls: Type[BaseOxmlElement], group_prop_name: str, successors: Sequence[str] + ): + """Add the appropriate methods to `element_cls`.""" + self._element_cls = element_cls + self._group_prop_name = group_prop_name + self._successors = successors + + self._add_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_get_or_change_to_method() + + def _add_get_or_change_to_method(self) -> None: + """Add a `get_or_change_to_x()` method to the element class for this child element.""" + + def get_or_change_to_child(obj: BaseOxmlElement): + child = getattr(obj, self._prop_name) + if child is not None: + return child + remove_group_method = getattr(obj, self._remove_group_method_name) + remove_group_method() + add_method = getattr(obj, self._add_method_name) + child = add_method() + return child + + get_or_change_to_child.__doc__ = ( + "Return the ``<%s>`` child, replacing any other group element if" " found." + ) % self._nsptagname + self._add_to_class(self._get_or_change_to_method_name, get_or_change_to_child) + + @property + def _prop_name(self): + """ + Calculate property name from tag name, e.g. a:schemeClr -> schemeClr. + """ + if ":" in self._nsptagname: + start = self._nsptagname.index(":") + 1 + else: + start = 0 + return self._nsptagname[start:] + + @lazyproperty + def _get_or_change_to_method_name(self): + return "get_or_change_to_%s" % self._prop_name + + @lazyproperty + def _remove_group_method_name(self): + return "_remove_%s" % self._group_prop_name + + +class OneAndOnlyOne(_BaseChildElement): + """Defines a required child element for MetaOxmlElement.""" + + def __init__(self, nsptagname: str): + super(OneAndOnlyOne, self).__init__(nsptagname, ()) + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """ + Add the appropriate methods to *element_cls*. + """ + super(OneAndOnlyOne, self).populate_class_members(element_cls, prop_name) + self._add_getter() + + @property + def _getter(self) -> Callable[[BaseOxmlElement], BaseOxmlElement]: + """Callable suitable for the "get" side of the property descriptor.""" + + def get_child_element(obj: BaseOxmlElement) -> BaseOxmlElement: + child = obj.find(qn(self._nsptagname)) + if child is None: + raise InvalidXmlError( + "required ``<%s>`` child element not present" % self._nsptagname + ) + return child + + get_child_element.__doc__ = "Required ``<%s>`` child element." % self._nsptagname + return get_child_element + + +class OneOrMore(_BaseChildElement): + """Defines a repeating child element for MetaOxmlElement that must appear at least once.""" + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """Add the appropriate methods to *element_cls*.""" + super(OneOrMore, self).populate_class_members(element_cls, prop_name) + self._add_list_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_public_adder() + delattr(element_cls, prop_name) + + def _add_public_adder(self) -> None: + """Add a public `.add_x()` method to the parent element class.""" + + def add_child(obj: BaseOxmlElement) -> BaseOxmlElement: + private_add_method = getattr(obj, self._add_method_name) + child = private_add_method() + return child + + add_child.__doc__ = ( + "Add a new ``<%s>`` child element unconditionally, inserted in t" + "he correct sequence." % self._nsptagname + ) + self._add_to_class(self._public_add_method_name, add_child) + + @lazyproperty + def _public_add_method_name(self): + """ + add_childElement() is public API for a repeating element, allowing + new elements to be added to the sequence. May be overridden to + provide a friendlier API to clients having domain appropriate + parameter names for required attributes. + """ + return "add_%s" % self._prop_name + + +class ZeroOrMore(_BaseChildElement): + """ + Defines an optional repeating child element for MetaOxmlElement. + """ + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """ + Add the appropriate methods to *element_cls*. + """ + super(ZeroOrMore, self).populate_class_members(element_cls, prop_name) + self._add_list_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + delattr(element_cls, prop_name) + + +class ZeroOrOne(_BaseChildElement): + """Defines an optional child element for MetaOxmlElement.""" + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """Add the appropriate methods to `element_cls`.""" + super(ZeroOrOne, self).populate_class_members(element_cls, prop_name) + self._add_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_get_or_adder() + self._add_remover() + + def _add_get_or_adder(self): + """Add a `.get_or_add_x()` method to the element class for this child element.""" + + def get_or_add_child(obj: BaseOxmlElement) -> BaseOxmlElement: + child = getattr(obj, self._prop_name) + if child is None: + add_method = getattr(obj, self._add_method_name) + child = add_method() + return child + + get_or_add_child.__doc__ = ( + "Return the ``<%s>`` child element, newly added if not present." + ) % self._nsptagname + self._add_to_class(self._get_or_add_method_name, get_or_add_child) + + def _add_remover(self): + """Add a `._remove_x()` method to the element class for this child element.""" + + def _remove_child(obj: BaseOxmlElement) -> None: + obj.remove_all(self._nsptagname) + + _remove_child.__doc__ = f"Remove all `{self._nsptagname}` child elements." + self._add_to_class(self._remove_method_name, _remove_child) + + @lazyproperty + def _get_or_add_method_name(self): + return "get_or_add_%s" % self._prop_name + + +class ZeroOrOneChoice(_BaseChildElement): + """An `EG_*` element group where at most one of its members may appear as a child.""" + + def __init__(self, choices: Iterable[Choice], successors: Iterable[str] = ()): + self._choices = tuple(choices) + self._successors = tuple(successors) + + def populate_class_members(self, element_cls: Type[BaseOxmlElement], prop_name: str): + """Add the appropriate methods to `element_cls`.""" + super(ZeroOrOneChoice, self).populate_class_members(element_cls, prop_name) + self._add_choice_getter() + for choice in self._choices: + choice.populate_class_members(element_cls, self._prop_name, self._successors) + self._add_group_remover() + + def _add_choice_getter(self): + """Add a read-only `.{prop_name}` property to the element class. + + The property returns the present member of this group, or |None| if none are present. + """ + property_ = property(self._choice_getter, None, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + def _add_group_remover(self): + """Add a `._remove_eg_x()` method to the element class for this choice group.""" + + def _remove_choice_group(obj: BaseOxmlElement) -> None: + for tagname in self._member_nsptagnames: + obj.remove_all(tagname) + + _remove_choice_group.__doc__ = "Remove the current choice group child element if present." + self._add_to_class(self._remove_choice_group_method_name, _remove_choice_group) + + @property + def _choice_getter(self): + """ + Return a function object suitable for the "get" side of the property + descriptor. + """ + + def get_group_member_element(obj: BaseOxmlElement) -> BaseOxmlElement | None: + return cast( + "BaseOxmlElement | None", obj.first_child_found_in(*self._member_nsptagnames) + ) + + get_group_member_element.__doc__ = ( + "Return the child element belonging to this element group, or " + "|None| if no member child is present." + ) + return get_group_member_element + + @lazyproperty + def _member_nsptagnames(self) -> list[str]: + """Sequence of namespace-prefixed tagnames, one for each member element of choice group.""" + return [choice.nsptagname for choice in self._choices] + + @lazyproperty + def _remove_choice_group_method_name(self): + """Function-name for choice remover.""" + return f"_remove_{self._prop_name}" + + +# -- lxml typing isn't quite right here, just ignore this error on _Element -- +class BaseOxmlElement(etree.ElementBase, metaclass=MetaOxmlElement): + """Effective base class for all custom element classes. + + Adds standardized behavior to all classes in one place. + """ + + def __repr__(self): + return "<%s '<%s>' at 0x%0x>" % ( + self.__class__.__name__, + self._nsptag, + id(self), + ) + + def first_child_found_in(self, *tagnames: str) -> _Element | None: + """First child with tag in `tagnames`, or None if not found.""" + for tagname in tagnames: + child = self.find(qn(tagname)) + if child is not None: + return child + return None + + def insert_element_before(self, elm: ElementBase, *tagnames: str): + successor = self.first_child_found_in(*tagnames) + if successor is not None: + successor.addprevious(elm) + else: + self.append(elm) + return elm + + def remove_all(self, *tagnames: str) -> None: + """Remove child elements with tagname (e.g. "a:p") in `tagnames`.""" + for tagname in tagnames: + matching = self.findall(qn(tagname)) + for child in matching: + self.remove(child) + + @property + def xml(self) -> str: + """XML string for this element, suitable for testing purposes. + + Pretty printed for readability and without an XML declaration at the top. + """ + return serialize_for_reading(self) + + def xpath(self, xpath_str: str) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] + """Override of `lxml` _Element.xpath() method. + + Provides standard Open XML namespace mapping (`nsmap`) in centralized location. + """ + return super().xpath(xpath_str, namespaces=_nsmap) + + @property + def _nsptag(self) -> str: + return NamespacePrefixedTag.from_clark_name(self.tag) |