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)
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.
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)
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.
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
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
Nonewhen 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
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
Nonewhen there was none :returns: whether this codec claims it
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.
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
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
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.
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.
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 )
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.
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.
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.
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.
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.
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.
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
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
335def codec_names() -> list[str]: 336 """The registered codec names, sorted.""" 337 with _registry_lock: 338 return sorted(_registry)
The registered codec names, sorted.