acemq_amqp.codec

Turning a payload into bytes, and bytes back into a payload.

The content type is what makes this work across languages: a Java producer says application/json and a Python consumer picks the codec that answers for it, without either end having been told about the other. A message that arrives with no content type at all is the interesting case, and the rule is the same one the .NET library uses — nothing has been ruled out, so every codec is a candidate and the first that can actually read the body wins.

Decoding returns the value rather than filling a destination that was passed in, which is where this departs from Go. Go's Decode takes a pointer because that is how every Go decoder works, and it then needs a second interface to get the content type to a composite codec that has to choose. Python can put the content type on decode as a defaulted argument, so one method serves both the codecs that need it and the codecs that do not.

  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"""Turning a payload into bytes, and bytes back into a payload.
 16
 17The content type is what makes this work across languages: a Java producer says
 18``application/json`` and a Python consumer picks the codec that answers for it,
 19without either end having been told about the other. A message that arrives with
 20no content type at all is the interesting case, and the rule is the same one the
 21.NET library uses — nothing has been ruled out, so every codec is a candidate
 22and the first that can actually read the body wins.
 23
 24Decoding returns the value rather than filling a destination that was passed in,
 25which is where this departs from Go. Go's ``Decode`` takes a pointer because
 26that is how every Go decoder works, and it then needs a second interface to get
 27the content type to a composite codec that has to choose. Python can put the
 28content type on ``decode`` as a defaulted argument, so one method serves both the
 29codecs that need it and the codecs that do not.
 30"""
 31
 32from __future__ import annotations
 33
 34import dataclasses
 35import json
 36import threading
 37from collections.abc import Callable
 38from typing import Any, Protocol, runtime_checkable
 39
 40from .ack import FatalError
 41
 42#: What :class:`JsonCodec` writes, and what Java, Go and .NET write.
 43JSON_CONTENT_TYPE = "application/json"
 44
 45#: What :class:`BytesCodec` writes when nothing better is known.
 46BYTES_CONTENT_TYPE = "application/octet-stream"
 47
 48#: What :class:`TextCodec` writes.
 49TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
 50
 51
 52@runtime_checkable
 53class Codec(Protocol):
 54    """Reads and writes message bodies in one format."""
 55
 56    @property
 57    def content_type(self) -> str:
 58        """What this codec writes onto a message it encodes."""
 59
 60    def encode(self, payload: Any) -> bytes:
 61        """Turns a payload into bytes.
 62
 63        :param payload: what the application wants to send
 64        :returns: the body
 65        :raises TypeError: when the payload is not something this codec writes
 66        """
 67
 68    def decode(self, body: bytes, content_type: str | None = None) -> Any:
 69        """Turns bytes back into a payload.
 70
 71        :param body: the message body
 72        :param content_type: what the sender said it was, or ``None`` when the
 73            sender said nothing
 74        :returns: the payload
 75        :raises FatalError: when the body cannot be read, which is fatal because
 76            the same bytes will not read any better on a second attempt
 77        """
 78
 79    def can_decode(self, content_type: str | None) -> bool:
 80        """Whether this codec should be offered a message.
 81
 82        :param content_type: the sender's content type, or ``None`` when there
 83            was none
 84        :returns: whether this codec claims it
 85        """
 86
 87
 88class JsonCodec:
 89    """Reads and writes JSON. The default, and the format every AceMQ library
 90    has without an extra dependency.
 91
 92    Dataclasses are encoded through :func:`dataclasses.asdict`, so a payload
 93    written as a dataclass goes on the wire as an object with its field names.
 94    Everything else is handed to :mod:`json` unchanged, which covers dicts,
 95    lists and the scalars.
 96
 97    Decoding produces dicts and lists rather than a class, because the wire says
 98    nothing about which class was meant. An application that wants its own type
 99    back constructs it from what it gets, where it can decide what a missing
100    field means.
101    """
102
103    @property
104    def content_type(self) -> str:
105        return JSON_CONTENT_TYPE
106
107    def encode(self, payload: Any) -> bytes:
108        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
109            payload = dataclasses.asdict(payload)
110        try:
111            return json.dumps(payload).encode("utf-8")
112        except TypeError as failure:
113            raise TypeError(
114                f"acemq: a {type(payload).__name__} is not something JSON can carry: {failure}"
115            ) from failure
116
117    def decode(self, body: bytes, content_type: str | None = None) -> Any:
118        try:
119            return json.loads(body)
120        except (ValueError, UnicodeDecodeError) as failure:
121            # Fatal rather than retryable: a body that is not JSON is not JSON
122            # the next time either, and retrying it only holds a queue open
123            # until the message ages out.
124            raise FatalError(f"acemq: this message is not JSON: {failure}") from failure
125
126    def can_decode(self, content_type: str | None) -> bool:
127        """Accepts JSON, any ``+json`` media type, and a message with none.
128
129        Answering for a message whose sender set no content type is deliberate
130        and is what the other libraries do: JSON is the default format, so an
131        untyped message is far more likely to be JSON than anything else, and
132        something has to read it.
133        """
134        if content_type is None or content_type == "":
135            return True
136        lowered = content_type.lower()
137        return (
138            lowered.startswith(JSON_CONTENT_TYPE)
139            or lowered.startswith("text/json")
140            or "+json" in lowered
141        )
142
143
144class BytesCodec:
145    """Passes bodies through without touching them.
146
147    For a payload that is already encoded — an image, something another system's
148    serializer produced — and for reading a message this process has no type
149    for. Replaying a dead-lettered message uses it: the bytes that were
150    committed are the bytes that should go back, and re-encoding through a class
151    that has since gained a field would produce something else.
152
153    It answers for every content type, which is why it has to be asked for
154    rather than found: put it last in a :class:`CompositeCodec`, or nothing
155    after it is ever reached.
156    """
157
158    @property
159    def content_type(self) -> str:
160        return BYTES_CONTENT_TYPE
161
162    def encode(self, payload: Any) -> bytes:
163        if payload is None:
164            return b""
165        if isinstance(payload, bytes):
166            return payload
167        if isinstance(payload, bytearray | memoryview):
168            return bytes(payload)
169        if isinstance(payload, str):
170            return payload.encode("utf-8")
171        raise TypeError(
172            f"acemq: BytesCodec cannot encode a {type(payload).__name__}; "
173            "it takes bytes or a string"
174        )
175
176    def decode(self, body: bytes, content_type: str | None = None) -> Any:
177        return bytes(body)
178
179    def can_decode(self, content_type: str | None) -> bool:
180        return True
181
182
183class TextCodec:
184    """Reads and writes text.
185
186    For messages that really are text — a line of a log, a command somebody
187    typed — rather than a structure that happens to be readable. Prefer
188    :class:`JsonCodec` for anything with fields.
189    """
190
191    @property
192    def content_type(self) -> str:
193        return TEXT_CONTENT_TYPE
194
195    def encode(self, payload: Any) -> bytes:
196        if isinstance(payload, str):
197            return payload.encode("utf-8")
198        if isinstance(payload, bytes):
199            return payload
200        raise TypeError(
201            f"acemq: TextCodec cannot encode a {type(payload).__name__}; "
202            "it takes a string or bytes"
203        )
204
205    def decode(self, body: bytes, content_type: str | None = None) -> Any:
206        try:
207            return body.decode("utf-8")
208        except UnicodeDecodeError as failure:
209            raise FatalError(f"acemq: this message is not UTF-8 text: {failure}") from failure
210
211    def can_decode(self, content_type: str | None) -> bool:
212        """Accepts ``text/*`` and nothing else.
213
214        Unlike :class:`JsonCodec` it does not answer for a message with no
215        content type, because there is no reason to think an untyped message is
216        text — and a codec that decodes anything into a string never fails, so
217        it would quietly hand a handler the printable rendering of a body it
218        should have refused.
219        """
220        return content_type is not None and content_type.lower().startswith("text/")
221
222
223class CompositeCodec:
224    """Picks a codec by the message's content type.
225
226    For a queue carrying more than one format — during a migration, or where
227    several producers were written at different times::
228
229        codec = CompositeCodec(JsonCodec(), TextCodec())
230
231    The first codec is what it writes. All of them are offered a message to
232    read, in the order they were given, and the first that claims the content
233    type *and* succeeds wins. Order matters when two overlap: :class:`BytesCodec`
234    answers for everything, so anything after it would never be reached.
235    """
236
237    def __init__(self, *codecs: Codec) -> None:
238        if not codecs:
239            raise ValueError("acemq: a CompositeCodec needs at least one codec in it")
240        self._codecs: tuple[Codec, ...] = codecs
241
242    @property
243    def codecs(self) -> tuple[Codec, ...]:
244        """The codecs, in the order they are tried."""
245        return self._codecs
246
247    @property
248    def content_type(self) -> str:
249        """The first codec's, since that is the one that encodes."""
250        return self._codecs[0].content_type
251
252    def encode(self, payload: Any) -> bytes:
253        return self._codecs[0].encode(payload)
254
255    def decode(self, body: bytes, content_type: str | None = None) -> Any:
256        """Reads a body with whichever codec recognises the content type.
257
258        With no content type nothing can be ruled out, so every codec is a
259        candidate rather than the first being assumed right. Candidates are
260        tried in turn and the first that reads the body wins — so a message that
261        claims one format and is written in another is still read — and only
262        when every one has refused is anything raised, carrying all the reasons
263        rather than just the last.
264        """
265        if content_type is None:
266            candidates = list(self._codecs)
267        else:
268            candidates = [codec for codec in self._codecs if codec.can_decode(content_type)]
269
270        if not candidates:
271            raise FatalError(
272                f"acemq: no codec here reads {content_type!r}; "
273                f"this one holds {self._describe()}"
274            )
275
276        refusals: list[str] = []
277        for codec in candidates:
278            try:
279                return codec.decode(body, content_type)
280            except Exception as failure:
281                # Collected rather than raised: a codec refusing says nothing
282                # about the next one, and the message that matters at the end is
283                # every reason together rather than whichever came last.
284                refusals.append(f"{type(codec).__name__}: {failure}")
285
286        raise FatalError(
287            "acemq: no codec could read this message. Tried " + "; ".join(refusals)
288        )
289
290    def can_decode(self, content_type: str | None) -> bool:
291        return any(codec.can_decode(content_type) for codec in self._codecs)
292
293    def _describe(self) -> str:
294        return ", ".join(codec.content_type for codec in self._codecs)
295
296    def __repr__(self) -> str:
297        return f"CompositeCodec({self._describe()})"
298
299
300_registry_lock = threading.Lock()
301_registry: dict[str, Callable[[], Codec]] = {}
302
303
304def register_codec(name: str, build: Callable[[], Codec]) -> None:
305    """Makes a codec available by name.
306
307    So that configuration can name a format without the code that reads the
308    configuration importing every format it might name. Registering the same
309    name twice replaces the first, which is what lets a test override a default.
310
311    :param name: what configuration will call it, such as ``"json"``
312    :param build: makes an instance
313    """
314    with _registry_lock:
315        _registry[name] = build
316
317
318def codec_by_name(name: str) -> Codec:
319    """Builds the codec registered under a name.
320
321    :param name: the registered name
322    :returns: a new instance
323    :raises KeyError: when nothing is registered under that name
324    """
325    with _registry_lock:
326        build = _registry.get(name)
327    if build is None:
328        raise KeyError(
329            f"acemq: no codec named {name!r} is registered; known: {', '.join(codec_names())}"
330        )
331    return build()
332
333
334def codec_names() -> list[str]:
335    """The registered codec names, sorted."""
336    with _registry_lock:
337        return sorted(_registry)
338
339
340register_codec("json", JsonCodec)
341register_codec("bytes", BytesCodec)
342register_codec("text", TextCodec)
343# The name .NET and Ruby register it under, so shared configuration works in all.
344register_codec("string", TextCodec)
JSON_CONTENT_TYPE = 'application/json'
BYTES_CONTENT_TYPE = 'application/octet-stream'
TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8'
@runtime_checkable
class Codec(typing.Protocol):
53@runtime_checkable
54class Codec(Protocol):
55    """Reads and writes message bodies in one format."""
56
57    @property
58    def content_type(self) -> str:
59        """What this codec writes onto a message it encodes."""
60
61    def encode(self, payload: Any) -> bytes:
62        """Turns a payload into bytes.
63
64        :param payload: what the application wants to send
65        :returns: the body
66        :raises TypeError: when the payload is not something this codec writes
67        """
68
69    def decode(self, body: bytes, content_type: str | None = None) -> Any:
70        """Turns bytes back into a payload.
71
72        :param body: the message body
73        :param content_type: what the sender said it was, or ``None`` when the
74            sender said nothing
75        :returns: the payload
76        :raises FatalError: when the body cannot be read, which is fatal because
77            the same bytes will not read any better on a second attempt
78        """
79
80    def can_decode(self, content_type: str | None) -> bool:
81        """Whether this codec should be offered a message.
82
83        :param content_type: the sender's content type, or ``None`` when there
84            was none
85        :returns: whether this codec claims it
86        """

Reads and writes message bodies in one format.

Codec(*args, **kwargs)
1771def _no_init_or_replace_init(self, *args, **kwargs):
1772    cls = type(self)
1773
1774    if cls._is_protocol:
1775        raise TypeError('Protocols cannot be instantiated')
1776
1777    # Already using a custom `__init__`. No need to calculate correct
1778    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1779    if cls.__init__ is not _no_init_or_replace_init:
1780        return
1781
1782    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1783    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1784    # searches for a proper new `__init__` in the MRO. The new `__init__`
1785    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1786    # instantiation of the protocol subclass will thus use the new
1787    # `__init__` and no longer call `_no_init_or_replace_init`.
1788    for base in cls.__mro__:
1789        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1790        if init is not _no_init_or_replace_init:
1791            cls.__init__ = init
1792            break
1793    else:
1794        # should not happen
1795        cls.__init__ = object.__init__
1796
1797    cls.__init__(self, *args, **kwargs)
content_type: str
57    @property
58    def content_type(self) -> str:
59        """What this codec writes onto a message it encodes."""

What this codec writes onto a message it encodes.

def encode(self, payload: Any) -> bytes:
61    def encode(self, payload: Any) -> bytes:
62        """Turns a payload into bytes.
63
64        :param payload: what the application wants to send
65        :returns: the body
66        :raises TypeError: when the payload is not something this codec writes
67        """

Turns a payload into bytes.

Parameters
  • payload: what the application wants to send :returns: the body
Raises
  • TypeError: when the payload is not something this codec writes
def decode(self, body: bytes, content_type: str | None = None) -> Any:
69    def decode(self, body: bytes, content_type: str | None = None) -> Any:
70        """Turns bytes back into a payload.
71
72        :param body: the message body
73        :param content_type: what the sender said it was, or ``None`` when the
74            sender said nothing
75        :returns: the payload
76        :raises FatalError: when the body cannot be read, which is fatal because
77            the same bytes will not read any better on a second attempt
78        """

Turns bytes back into a payload.

Parameters
  • body: the message body
  • content_type: what the sender said it was, or None when the sender said nothing :returns: the payload
Raises
  • FatalError: when the body cannot be read, which is fatal because the same bytes will not read any better on a second attempt
def can_decode(self, content_type: str | None) -> bool:
80    def can_decode(self, content_type: str | None) -> bool:
81        """Whether this codec should be offered a message.
82
83        :param content_type: the sender's content type, or ``None`` when there
84            was none
85        :returns: whether this codec claims it
86        """

Whether this codec should be offered a message.

Parameters
  • content_type: the sender's content type, or None when there was none :returns: whether this codec claims it
class JsonCodec:
 89class JsonCodec:
 90    """Reads and writes JSON. The default, and the format every AceMQ library
 91    has without an extra dependency.
 92
 93    Dataclasses are encoded through :func:`dataclasses.asdict`, so a payload
 94    written as a dataclass goes on the wire as an object with its field names.
 95    Everything else is handed to :mod:`json` unchanged, which covers dicts,
 96    lists and the scalars.
 97
 98    Decoding produces dicts and lists rather than a class, because the wire says
 99    nothing about which class was meant. An application that wants its own type
100    back constructs it from what it gets, where it can decide what a missing
101    field means.
102    """
103
104    @property
105    def content_type(self) -> str:
106        return JSON_CONTENT_TYPE
107
108    def encode(self, payload: Any) -> bytes:
109        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
110            payload = dataclasses.asdict(payload)
111        try:
112            return json.dumps(payload).encode("utf-8")
113        except TypeError as failure:
114            raise TypeError(
115                f"acemq: a {type(payload).__name__} is not something JSON can carry: {failure}"
116            ) from failure
117
118    def decode(self, body: bytes, content_type: str | None = None) -> Any:
119        try:
120            return json.loads(body)
121        except (ValueError, UnicodeDecodeError) as failure:
122            # Fatal rather than retryable: a body that is not JSON is not JSON
123            # the next time either, and retrying it only holds a queue open
124            # until the message ages out.
125            raise FatalError(f"acemq: this message is not JSON: {failure}") from failure
126
127    def can_decode(self, content_type: str | None) -> bool:
128        """Accepts JSON, any ``+json`` media type, and a message with none.
129
130        Answering for a message whose sender set no content type is deliberate
131        and is what the other libraries do: JSON is the default format, so an
132        untyped message is far more likely to be JSON than anything else, and
133        something has to read it.
134        """
135        if content_type is None or content_type == "":
136            return True
137        lowered = content_type.lower()
138        return (
139            lowered.startswith(JSON_CONTENT_TYPE)
140            or lowered.startswith("text/json")
141            or "+json" in lowered
142        )

Reads and writes JSON. The default, and the format every AceMQ library has without an extra dependency.

Dataclasses are encoded through dataclasses.asdict(), so a payload written as a dataclass goes on the wire as an object with its field names. Everything else is handed to json unchanged, which covers dicts, lists and the scalars.

Decoding produces dicts and lists rather than a class, because the wire says nothing about which class was meant. An application that wants its own type back constructs it from what it gets, where it can decide what a missing field means.

content_type: str
104    @property
105    def content_type(self) -> str:
106        return JSON_CONTENT_TYPE
def encode(self, payload: Any) -> bytes:
108    def encode(self, payload: Any) -> bytes:
109        if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
110            payload = dataclasses.asdict(payload)
111        try:
112            return json.dumps(payload).encode("utf-8")
113        except TypeError as failure:
114            raise TypeError(
115                f"acemq: a {type(payload).__name__} is not something JSON can carry: {failure}"
116            ) from failure
def decode(self, body: bytes, content_type: str | None = None) -> Any:
118    def decode(self, body: bytes, content_type: str | None = None) -> Any:
119        try:
120            return json.loads(body)
121        except (ValueError, UnicodeDecodeError) as failure:
122            # Fatal rather than retryable: a body that is not JSON is not JSON
123            # the next time either, and retrying it only holds a queue open
124            # until the message ages out.
125            raise FatalError(f"acemq: this message is not JSON: {failure}") from failure
def can_decode(self, content_type: str | None) -> bool:
127    def can_decode(self, content_type: str | None) -> bool:
128        """Accepts JSON, any ``+json`` media type, and a message with none.
129
130        Answering for a message whose sender set no content type is deliberate
131        and is what the other libraries do: JSON is the default format, so an
132        untyped message is far more likely to be JSON than anything else, and
133        something has to read it.
134        """
135        if content_type is None or content_type == "":
136            return True
137        lowered = content_type.lower()
138        return (
139            lowered.startswith(JSON_CONTENT_TYPE)
140            or lowered.startswith("text/json")
141            or "+json" in lowered
142        )

Accepts JSON, any +json media type, and a message with none.

Answering for a message whose sender set no content type is deliberate and is what the other libraries do: JSON is the default format, so an untyped message is far more likely to be JSON than anything else, and something has to read it.

class BytesCodec:
145class BytesCodec:
146    """Passes bodies through without touching them.
147
148    For a payload that is already encoded — an image, something another system's
149    serializer produced — and for reading a message this process has no type
150    for. Replaying a dead-lettered message uses it: the bytes that were
151    committed are the bytes that should go back, and re-encoding through a class
152    that has since gained a field would produce something else.
153
154    It answers for every content type, which is why it has to be asked for
155    rather than found: put it last in a :class:`CompositeCodec`, or nothing
156    after it is ever reached.
157    """
158
159    @property
160    def content_type(self) -> str:
161        return BYTES_CONTENT_TYPE
162
163    def encode(self, payload: Any) -> bytes:
164        if payload is None:
165            return b""
166        if isinstance(payload, bytes):
167            return payload
168        if isinstance(payload, bytearray | memoryview):
169            return bytes(payload)
170        if isinstance(payload, str):
171            return payload.encode("utf-8")
172        raise TypeError(
173            f"acemq: BytesCodec cannot encode a {type(payload).__name__}; "
174            "it takes bytes or a string"
175        )
176
177    def decode(self, body: bytes, content_type: str | None = None) -> Any:
178        return bytes(body)
179
180    def can_decode(self, content_type: str | None) -> bool:
181        return True

Passes bodies through without touching them.

For a payload that is already encoded — an image, something another system's serializer produced — and for reading a message this process has no type for. Replaying a dead-lettered message uses it: the bytes that were committed are the bytes that should go back, and re-encoding through a class that has since gained a field would produce something else.

It answers for every content type, which is why it has to be asked for rather than found: put it last in a CompositeCodec, or nothing after it is ever reached.

content_type: str
159    @property
160    def content_type(self) -> str:
161        return BYTES_CONTENT_TYPE
def encode(self, payload: Any) -> bytes:
163    def encode(self, payload: Any) -> bytes:
164        if payload is None:
165            return b""
166        if isinstance(payload, bytes):
167            return payload
168        if isinstance(payload, bytearray | memoryview):
169            return bytes(payload)
170        if isinstance(payload, str):
171            return payload.encode("utf-8")
172        raise TypeError(
173            f"acemq: BytesCodec cannot encode a {type(payload).__name__}; "
174            "it takes bytes or a string"
175        )
def decode(self, body: bytes, content_type: str | None = None) -> Any:
177    def decode(self, body: bytes, content_type: str | None = None) -> Any:
178        return bytes(body)
def can_decode(self, content_type: str | None) -> bool:
180    def can_decode(self, content_type: str | None) -> bool:
181        return True
class TextCodec:
184class TextCodec:
185    """Reads and writes text.
186
187    For messages that really are text — a line of a log, a command somebody
188    typed — rather than a structure that happens to be readable. Prefer
189    :class:`JsonCodec` for anything with fields.
190    """
191
192    @property
193    def content_type(self) -> str:
194        return TEXT_CONTENT_TYPE
195
196    def encode(self, payload: Any) -> bytes:
197        if isinstance(payload, str):
198            return payload.encode("utf-8")
199        if isinstance(payload, bytes):
200            return payload
201        raise TypeError(
202            f"acemq: TextCodec cannot encode a {type(payload).__name__}; "
203            "it takes a string or bytes"
204        )
205
206    def decode(self, body: bytes, content_type: str | None = None) -> Any:
207        try:
208            return body.decode("utf-8")
209        except UnicodeDecodeError as failure:
210            raise FatalError(f"acemq: this message is not UTF-8 text: {failure}") from failure
211
212    def can_decode(self, content_type: str | None) -> bool:
213        """Accepts ``text/*`` and nothing else.
214
215        Unlike :class:`JsonCodec` it does not answer for a message with no
216        content type, because there is no reason to think an untyped message is
217        text — and a codec that decodes anything into a string never fails, so
218        it would quietly hand a handler the printable rendering of a body it
219        should have refused.
220        """
221        return content_type is not None and content_type.lower().startswith("text/")

Reads and writes text.

For messages that really are text — a line of a log, a command somebody typed — rather than a structure that happens to be readable. Prefer JsonCodec for anything with fields.

content_type: str
192    @property
193    def content_type(self) -> str:
194        return TEXT_CONTENT_TYPE
def encode(self, payload: Any) -> bytes:
196    def encode(self, payload: Any) -> bytes:
197        if isinstance(payload, str):
198            return payload.encode("utf-8")
199        if isinstance(payload, bytes):
200            return payload
201        raise TypeError(
202            f"acemq: TextCodec cannot encode a {type(payload).__name__}; "
203            "it takes a string or bytes"
204        )
def decode(self, body: bytes, content_type: str | None = None) -> Any:
206    def decode(self, body: bytes, content_type: str | None = None) -> Any:
207        try:
208            return body.decode("utf-8")
209        except UnicodeDecodeError as failure:
210            raise FatalError(f"acemq: this message is not UTF-8 text: {failure}") from failure
def can_decode(self, content_type: str | None) -> bool:
212    def can_decode(self, content_type: str | None) -> bool:
213        """Accepts ``text/*`` and nothing else.
214
215        Unlike :class:`JsonCodec` it does not answer for a message with no
216        content type, because there is no reason to think an untyped message is
217        text — and a codec that decodes anything into a string never fails, so
218        it would quietly hand a handler the printable rendering of a body it
219        should have refused.
220        """
221        return content_type is not None and content_type.lower().startswith("text/")

Accepts text/* and nothing else.

Unlike JsonCodec it does not answer for a message with no content type, because there is no reason to think an untyped message is text — and a codec that decodes anything into a string never fails, so it would quietly hand a handler the printable rendering of a body it should have refused.

class CompositeCodec:
224class CompositeCodec:
225    """Picks a codec by the message's content type.
226
227    For a queue carrying more than one format — during a migration, or where
228    several producers were written at different times::
229
230        codec = CompositeCodec(JsonCodec(), TextCodec())
231
232    The first codec is what it writes. All of them are offered a message to
233    read, in the order they were given, and the first that claims the content
234    type *and* succeeds wins. Order matters when two overlap: :class:`BytesCodec`
235    answers for everything, so anything after it would never be reached.
236    """
237
238    def __init__(self, *codecs: Codec) -> None:
239        if not codecs:
240            raise ValueError("acemq: a CompositeCodec needs at least one codec in it")
241        self._codecs: tuple[Codec, ...] = codecs
242
243    @property
244    def codecs(self) -> tuple[Codec, ...]:
245        """The codecs, in the order they are tried."""
246        return self._codecs
247
248    @property
249    def content_type(self) -> str:
250        """The first codec's, since that is the one that encodes."""
251        return self._codecs[0].content_type
252
253    def encode(self, payload: Any) -> bytes:
254        return self._codecs[0].encode(payload)
255
256    def decode(self, body: bytes, content_type: str | None = None) -> Any:
257        """Reads a body with whichever codec recognises the content type.
258
259        With no content type nothing can be ruled out, so every codec is a
260        candidate rather than the first being assumed right. Candidates are
261        tried in turn and the first that reads the body wins — so a message that
262        claims one format and is written in another is still read — and only
263        when every one has refused is anything raised, carrying all the reasons
264        rather than just the last.
265        """
266        if content_type is None:
267            candidates = list(self._codecs)
268        else:
269            candidates = [codec for codec in self._codecs if codec.can_decode(content_type)]
270
271        if not candidates:
272            raise FatalError(
273                f"acemq: no codec here reads {content_type!r}; "
274                f"this one holds {self._describe()}"
275            )
276
277        refusals: list[str] = []
278        for codec in candidates:
279            try:
280                return codec.decode(body, content_type)
281            except Exception as failure:
282                # Collected rather than raised: a codec refusing says nothing
283                # about the next one, and the message that matters at the end is
284                # every reason together rather than whichever came last.
285                refusals.append(f"{type(codec).__name__}: {failure}")
286
287        raise FatalError(
288            "acemq: no codec could read this message. Tried " + "; ".join(refusals)
289        )
290
291    def can_decode(self, content_type: str | None) -> bool:
292        return any(codec.can_decode(content_type) for codec in self._codecs)
293
294    def _describe(self) -> str:
295        return ", ".join(codec.content_type for codec in self._codecs)
296
297    def __repr__(self) -> str:
298        return f"CompositeCodec({self._describe()})"

Picks a codec by the message's content type.

For a queue carrying more than one format — during a migration, or where several producers were written at different times::

codec = CompositeCodec(JsonCodec(), TextCodec())

The first codec is what it writes. All of them are offered a message to read, in the order they were given, and the first that claims the content type and succeeds wins. Order matters when two overlap: BytesCodec answers for everything, so anything after it would never be reached.

CompositeCodec(*codecs: Codec)
238    def __init__(self, *codecs: Codec) -> None:
239        if not codecs:
240            raise ValueError("acemq: a CompositeCodec needs at least one codec in it")
241        self._codecs: tuple[Codec, ...] = codecs
codecs: tuple[Codec, ...]
243    @property
244    def codecs(self) -> tuple[Codec, ...]:
245        """The codecs, in the order they are tried."""
246        return self._codecs

The codecs, in the order they are tried.

content_type: str
248    @property
249    def content_type(self) -> str:
250        """The first codec's, since that is the one that encodes."""
251        return self._codecs[0].content_type

The first codec's, since that is the one that encodes.

def encode(self, payload: Any) -> bytes:
253    def encode(self, payload: Any) -> bytes:
254        return self._codecs[0].encode(payload)
def decode(self, body: bytes, content_type: str | None = None) -> Any:
256    def decode(self, body: bytes, content_type: str | None = None) -> Any:
257        """Reads a body with whichever codec recognises the content type.
258
259        With no content type nothing can be ruled out, so every codec is a
260        candidate rather than the first being assumed right. Candidates are
261        tried in turn and the first that reads the body wins — so a message that
262        claims one format and is written in another is still read — and only
263        when every one has refused is anything raised, carrying all the reasons
264        rather than just the last.
265        """
266        if content_type is None:
267            candidates = list(self._codecs)
268        else:
269            candidates = [codec for codec in self._codecs if codec.can_decode(content_type)]
270
271        if not candidates:
272            raise FatalError(
273                f"acemq: no codec here reads {content_type!r}; "
274                f"this one holds {self._describe()}"
275            )
276
277        refusals: list[str] = []
278        for codec in candidates:
279            try:
280                return codec.decode(body, content_type)
281            except Exception as failure:
282                # Collected rather than raised: a codec refusing says nothing
283                # about the next one, and the message that matters at the end is
284                # every reason together rather than whichever came last.
285                refusals.append(f"{type(codec).__name__}: {failure}")
286
287        raise FatalError(
288            "acemq: no codec could read this message. Tried " + "; ".join(refusals)
289        )

Reads a body with whichever codec recognises the content type.

With no content type nothing can be ruled out, so every codec is a candidate rather than the first being assumed right. Candidates are tried in turn and the first that reads the body wins — so a message that claims one format and is written in another is still read — and only when every one has refused is anything raised, carrying all the reasons rather than just the last.

def can_decode(self, content_type: str | None) -> bool:
291    def can_decode(self, content_type: str | None) -> bool:
292        return any(codec.can_decode(content_type) for codec in self._codecs)
def register_codec(name: str, build: Callable[[], Codec]) -> None:
305def register_codec(name: str, build: Callable[[], Codec]) -> None:
306    """Makes a codec available by name.
307
308    So that configuration can name a format without the code that reads the
309    configuration importing every format it might name. Registering the same
310    name twice replaces the first, which is what lets a test override a default.
311
312    :param name: what configuration will call it, such as ``"json"``
313    :param build: makes an instance
314    """
315    with _registry_lock:
316        _registry[name] = build

Makes a codec available by name.

So that configuration can name a format without the code that reads the configuration importing every format it might name. Registering the same name twice replaces the first, which is what lets a test override a default.

Parameters
  • name: what configuration will call it, such as "json"
  • build: makes an instance
def codec_by_name(name: str) -> Codec:
319def codec_by_name(name: str) -> Codec:
320    """Builds the codec registered under a name.
321
322    :param name: the registered name
323    :returns: a new instance
324    :raises KeyError: when nothing is registered under that name
325    """
326    with _registry_lock:
327        build = _registry.get(name)
328    if build is None:
329        raise KeyError(
330            f"acemq: no codec named {name!r} is registered; known: {', '.join(codec_names())}"
331        )
332    return build()

Builds the codec registered under a name.

Parameters
  • name: the registered name :returns: a new instance
Raises
  • KeyError: when nothing is registered under that name
def codec_names() -> list[str]:
335def codec_names() -> list[str]:
336    """The registered codec names, sorted."""
337    with _registry_lock:
338        return sorted(_registry)

The registered codec names, sorted.