acemq_amqp.codecs.xml

Reads and writes XML, with no extra to install::

from acemq_amqp.codecs.xml import XmlCodec

The only one of the five that needs nothing: it is written against xml.parsers.expat and xml.etree.ElementTree, both standard library. There is deliberately no [xml] extra, because there would be nothing in it. defusedxml was considered and rejected for the same reason — what it does is turn handlers off, and this module turns the same handlers off directly and in a way nobody can turn back on.

Every document with a DTD is refused, and that is not configurable.

A message body arrives from a queue, which is exactly the sort of place a message from somewhere unexpected turns up, and an XML parser handed one is a well-known way to read files off the machine and open connections on behalf of whoever sent it. Python's position is better than it is often given credit for and worse than it needs to be:

  • External entities are already inert. <!ENTITY x SYSTEM "file:///etc/passwd"> is not resolved by xml.etree.ElementTree; the reference fails as an undefined entity. So plain XXE and DTD retrieval are not live hazards.
  • Internal entity expansion is not inert. The billion-laughs attack needs no network access and no file access — a few nested internal entities expand to gigabytes inside the parser and take a consumer down — and ElementTree.fromstring expands them happily. This was verified against the interpreter this library is tested on rather than taken from a table.

So rather than disabling the individual hazards, this refuses the whole construct they all need: the parser's StartDoctypeDeclHandler, EntityDeclHandler, UnparsedEntityDeclHandler and ExternalEntityRefHandler each raise. A body carrying <!DOCTYPE is a ~acemq_amqp.ack.FatalError naming the reason, whatever the DTD would have said. There is no constructor argument to relax it, matching the Java codec, which says the same thing about the same decision: the configuration would only ever be wrong.

A message body has no legitimate use for a DTD. It is one document, produced by a serializer at the other end, and neither Jackson's XmlMapper nor Go's encoding/xml writes one.

The Python payload is a dict, and XML has no types. Everything decodes to a string, a nested dict, or a list where a tag repeats — the same thing Jackson gives when an XML message is read into a Map. A consumer that wants an int converts it, where it can decide what a missing or unparseable field means.

  1# Copyright 2026 AceMQ.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""Reads and writes XML, with no extra to install::
 16
 17    from acemq_amqp.codecs.xml import XmlCodec
 18
 19The only one of the five that needs nothing: it is written against
 20:mod:`xml.parsers.expat` and :mod:`xml.etree.ElementTree`, both standard
 21library. There is deliberately no ``[xml]`` extra, because there would be
 22nothing in it. ``defusedxml`` was considered and rejected for the same reason —
 23what it does is turn handlers off, and this module turns the same handlers off
 24directly and in a way nobody can turn back on.
 25
 26**Every document with a DTD is refused, and that is not configurable.**
 27
 28A message body arrives from a queue, which is exactly the sort of place a
 29message from somewhere unexpected turns up, and an XML parser handed one is a
 30well-known way to read files off the machine and open connections on behalf of
 31whoever sent it. Python's position is better than it is often given credit for
 32and worse than it needs to be:
 33
 34* External entities are already inert. ``<!ENTITY x SYSTEM "file:///etc/passwd">``
 35  is not resolved by :mod:`xml.etree.ElementTree`; the reference fails as an
 36  undefined entity. So plain XXE and DTD retrieval are not live hazards.
 37* **Internal** entity expansion is not inert. The billion-laughs attack needs no
 38  network access and no file access — a few nested internal entities expand to
 39  gigabytes inside the parser and take a consumer down — and
 40  ``ElementTree.fromstring`` expands them happily. This was verified against the
 41  interpreter this library is tested on rather than taken from a table.
 42
 43So rather than disabling the individual hazards, this refuses the whole
 44construct they all need: the parser's ``StartDoctypeDeclHandler``,
 45``EntityDeclHandler``, ``UnparsedEntityDeclHandler`` and
 46``ExternalEntityRefHandler`` each raise. A body carrying ``<!DOCTYPE`` is a
 47:class:`~acemq_amqp.ack.FatalError` naming the reason, whatever the DTD would
 48have said. There is no constructor argument to relax it, matching the Java
 49codec, which says the same thing about the same decision: the configuration
 50would only ever be wrong.
 51
 52A message body has no legitimate use for a DTD. It is one document, produced by
 53a serializer at the other end, and neither Jackson's ``XmlMapper`` nor Go's
 54``encoding/xml`` writes one.
 55
 56**The Python payload is a dict, and XML has no types.** Everything decodes to a
 57string, a nested dict, or a list where a tag repeats — the same thing Jackson
 58gives when an XML message is read into a ``Map``. A consumer that wants an
 59``int`` converts it, where it can decide what a missing or unparseable field
 60means.
 61"""
 62
 63from __future__ import annotations
 64
 65import dataclasses
 66import xml.etree.ElementTree as ElementTree
 67from typing import Any
 68from xml.parsers import expat
 69
 70from ..ack import FatalError
 71from ..codec import register_codec
 72
 73#: What this codec writes, and what Java, Go and .NET write.
 74XML_CONTENT_TYPE = "application/xml"
 75
 76#: Every content type this codec answers for, besides any ``+xml`` suffix —
 77#: ``application/atom+xml`` and the rest. Java and Go accept exactly these.
 78XML_ACCEPTS = (XML_CONTENT_TYPE, "text/xml")
 79
 80#: The root element name used for a payload that does not carry one — a plain
 81#: dict has no class name to take it from.
 82DEFAULT_ROOT = "message"
 83
 84_DTD_REFUSED = (
 85    "acemq: this message carries a document type declaration. A message body has no "
 86    "use for one, and a parser that reads DTDs will expand entities on behalf of "
 87    "whoever sent the message. This codec refuses every DTD and the refusal is not "
 88    "configurable."
 89)
 90
 91
 92def _refuse_dtd(*_: object, **__: object) -> None:
 93    """Every DTD-shaped callback expat has, wired to the same refusal."""
 94    raise FatalError(_DTD_REFUSED)
 95
 96
 97class XmlCodec:
 98    """Reads and writes XML.
 99
100    :param root: the element name to wrap a payload in when it has no name of
101        its own. A dataclass uses its class name, the way Jackson uses the
102        class's simple name and Go uses the struct's; a plain mapping has
103        nothing to take one from, so this is used
104    """
105
106    def __init__(self, root: str = DEFAULT_ROOT) -> None:
107        if not root:
108            raise ValueError("acemq: an XML codec needs a root element name")
109        self._root = root
110
111    @property
112    def content_type(self) -> str:
113        return XML_CONTENT_TYPE
114
115    @property
116    def root(self) -> str:
117        """The root element name used for payloads that carry no name."""
118        return self._root
119
120    def encode(self, payload: Any) -> bytes:
121        name = self._root
122        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
123            # The class name, which is what Jackson and encoding/xml write, so a
124            # dataclass published here reaches Java looking like the record it
125            # would have come from.
126            name = type(payload).__name__
127            payload = dataclasses.asdict(payload)
128
129        if not isinstance(payload, dict):
130            raise TypeError(
131                f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document "
132                "has one root element with named children, so the top level has to be a "
133                "mapping or a dataclass. Wrap a list or a scalar in a mapping with a "
134                "named key, or use JsonCodec."
135            )
136
137        element = ElementTree.Element(name)
138        _fill(element, payload)
139        written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False)
140        return written
141
142    def decode(self, body: bytes, content_type: str | None = None) -> Any:
143        return _to_value(_parse(body))
144
145    def can_decode(self, content_type: str | None) -> bool:
146        """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type.
147
148        Never a message with no content type. XML is rarely what arrives
149        unannounced, and a codec that guesses wrong here turns a readable
150        message into a rejected one.
151        """
152        if not content_type:
153            return False
154        lowered = content_type.lower()
155        return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered
156
157    def __repr__(self) -> str:
158        return f"XmlCodec({self._root!r})"
159
160
161def _parse(body: bytes) -> ElementTree.Element:
162    """Parses a body with a parser that will not read a DTD.
163
164    Built here rather than reused, because an expat parser is single-use and
165    because a shared one would be a shared mutable across every consumer thread.
166    """
167    builder = ElementTree.TreeBuilder()
168    # Namespace processing on, so a namespaced document is read rather than
169    # refused for an undeclared prefix, and so xmlns attributes do not arrive as
170    # data. Tags come through as "uri}local" and are cut back to the local name
171    # below: a consumer wants the field, not the namespace it was declared in,
172    # which is also what Jackson gives when XML is read into a Map.
173    # Any, because the handler attributes expat exposes are typed for the
174    # signatures it calls them with and every one of these is the same refusal.
175    parser: Any = expat.ParserCreate(namespace_separator="}")
176    parser.StartDoctypeDeclHandler = _refuse_dtd
177    parser.EntityDeclHandler = _refuse_dtd
178    parser.UnparsedEntityDeclHandler = _refuse_dtd
179    parser.ExternalEntityRefHandler = _refuse_dtd
180    parser.buffer_text = True
181    parser.StartElementHandler = lambda tag, attributes: builder.start(tag, attributes)
182    parser.EndElementHandler = builder.end
183    parser.CharacterDataHandler = builder.data
184
185    try:
186        parser.Parse(body, True)
187        return builder.close()
188    except FatalError:
189        raise
190    except (expat.ExpatError, ValueError) as failure:
191        # Fatal rather than retryable: bytes that are not XML will not become
192        # XML on a redelivery.
193        raise FatalError(f"acemq: this message is not XML: {failure}") from failure
194
195
196def _fill(element: ElementTree.Element, mapping: dict[Any, Any]) -> None:
197    """Writes a mapping's items as child elements of ``element``."""
198    for key, value in mapping.items():
199        _append(element, str(key), value)
200
201
202def _append(parent: ElementTree.Element, name: str, value: Any) -> None:
203    if isinstance(value, list | tuple):
204        # A repeated tag, which is how every XML serializer writes a sequence
205        # and how this codec reads one back.
206        for item in value:
207            _append(parent, name, item)
208        return
209
210    child = ElementTree.SubElement(parent, name)
211    if dataclasses.is_dataclass(value) and not isinstance(value, type):
212        value = dataclasses.asdict(value)
213    if isinstance(value, dict):
214        _fill(child, value)
215    elif value is None:
216        # An empty element, which reads back as the empty string. XML has no
217        # null, and inventing xsi:nil here would produce something Java and Go
218        # do not write.
219        child.text = None
220    elif isinstance(value, bool):
221        # Lowercase, which is what Jackson and encoding/xml write. Python's
222        # str(True) would put "True" on the wire and nothing else would read it.
223        child.text = "true" if value else "false"
224    else:
225        child.text = str(value)
226
227
228def _local(tag: str) -> str:
229    """``uri}local`` back to ``local``."""
230    _, separator, local = tag.rpartition("}")
231    return local if separator else tag
232
233
234def _to_value(element: ElementTree.Element) -> Any:
235    """One element as a string, or as a dict when it has children or attributes."""
236    result: dict[str, Any] = {}
237    for key, value in element.attrib.items():
238        result[_local(key)] = value
239
240    for child in element:
241        name = _local(child.tag)
242        value = _to_value(child)
243        existing = result.get(name)
244        if existing is None and name not in result:
245            result[name] = value
246        elif isinstance(existing, list):
247            existing.append(value)
248        else:
249            # The second one of a repeated tag turns the first into a list,
250            # which is the only point at which a repeat is visible.
251            result[name] = [existing, value]
252
253    if result:
254        return result
255    return element.text or ""
256
257
258register_codec("xml", XmlCodec)
XML_CONTENT_TYPE = 'application/xml'
XML_ACCEPTS = ('application/xml', 'text/xml')
DEFAULT_ROOT = 'message'
class XmlCodec:
 98class XmlCodec:
 99    """Reads and writes XML.
100
101    :param root: the element name to wrap a payload in when it has no name of
102        its own. A dataclass uses its class name, the way Jackson uses the
103        class's simple name and Go uses the struct's; a plain mapping has
104        nothing to take one from, so this is used
105    """
106
107    def __init__(self, root: str = DEFAULT_ROOT) -> None:
108        if not root:
109            raise ValueError("acemq: an XML codec needs a root element name")
110        self._root = root
111
112    @property
113    def content_type(self) -> str:
114        return XML_CONTENT_TYPE
115
116    @property
117    def root(self) -> str:
118        """The root element name used for payloads that carry no name."""
119        return self._root
120
121    def encode(self, payload: Any) -> bytes:
122        name = self._root
123        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
124            # The class name, which is what Jackson and encoding/xml write, so a
125            # dataclass published here reaches Java looking like the record it
126            # would have come from.
127            name = type(payload).__name__
128            payload = dataclasses.asdict(payload)
129
130        if not isinstance(payload, dict):
131            raise TypeError(
132                f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document "
133                "has one root element with named children, so the top level has to be a "
134                "mapping or a dataclass. Wrap a list or a scalar in a mapping with a "
135                "named key, or use JsonCodec."
136            )
137
138        element = ElementTree.Element(name)
139        _fill(element, payload)
140        written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False)
141        return written
142
143    def decode(self, body: bytes, content_type: str | None = None) -> Any:
144        return _to_value(_parse(body))
145
146    def can_decode(self, content_type: str | None) -> bool:
147        """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type.
148
149        Never a message with no content type. XML is rarely what arrives
150        unannounced, and a codec that guesses wrong here turns a readable
151        message into a rejected one.
152        """
153        if not content_type:
154            return False
155        lowered = content_type.lower()
156        return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered
157
158    def __repr__(self) -> str:
159        return f"XmlCodec({self._root!r})"

Reads and writes XML.

Parameters
  • root: the element name to wrap a payload in when it has no name of its own. A dataclass uses its class name, the way Jackson uses the class's simple name and Go uses the struct's; a plain mapping has nothing to take one from, so this is used
XmlCodec(root: str = 'message')
107    def __init__(self, root: str = DEFAULT_ROOT) -> None:
108        if not root:
109            raise ValueError("acemq: an XML codec needs a root element name")
110        self._root = root
content_type: str
112    @property
113    def content_type(self) -> str:
114        return XML_CONTENT_TYPE
root: str
116    @property
117    def root(self) -> str:
118        """The root element name used for payloads that carry no name."""
119        return self._root

The root element name used for payloads that carry no name.

def encode(self, payload: Any) -> bytes:
121    def encode(self, payload: Any) -> bytes:
122        name = self._root
123        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
124            # The class name, which is what Jackson and encoding/xml write, so a
125            # dataclass published here reaches Java looking like the record it
126            # would have come from.
127            name = type(payload).__name__
128            payload = dataclasses.asdict(payload)
129
130        if not isinstance(payload, dict):
131            raise TypeError(
132                f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document "
133                "has one root element with named children, so the top level has to be a "
134                "mapping or a dataclass. Wrap a list or a scalar in a mapping with a "
135                "named key, or use JsonCodec."
136            )
137
138        element = ElementTree.Element(name)
139        _fill(element, payload)
140        written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False)
141        return written
def decode(self, body: bytes, content_type: str | None = None) -> Any:
143    def decode(self, body: bytes, content_type: str | None = None) -> Any:
144        return _to_value(_parse(body))
def can_decode(self, content_type: str | None) -> bool:
146    def can_decode(self, content_type: str | None) -> bool:
147        """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type.
148
149        Never a message with no content type. XML is rarely what arrives
150        unannounced, and a codec that guesses wrong here turns a readable
151        message into a rejected one.
152        """
153        if not content_type:
154            return False
155        lowered = content_type.lower()
156        return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered

Accepts application/xml, text/xml and any +xml type.

Never a message with no content type. XML is rarely what arrives unannounced, and a codec that guesses wrong here turns a readable message into a rejected one.