Skip to content

Commit 2b810d5

Browse files
Add ServiceBusReceivedMessage.from_bytes() classmethod (fixes #43979) (#45142)
* Add ServiceBusReceivedMessage.from_bytes() classmethod (fixes #43979) Add a from_bytes() factory method to ServiceBusReceivedMessage that constructs an instance from raw AMQP payload bytes without requiring the deprecated uamqp library. This mirrors the existing EventData.from_bytes() pattern in azure-eventhub. The method uses the internal pyamqp decode_payload() to parse the binary AMQP message and wraps it in an AmqpAnnotatedMessage, making all standard message properties (body, application_properties, annotations, etc.) accessible. Includes unit tests for data, value, and sequence body types. * Document settled state behavior in from_bytes docstring * Bump version to 7.15.0 for unreleased changelog entry * Fix mypy.ini python_version: 3.7 -> 3.9 (mypy 1.18+ requires >= 3.9) * Disable mypy check for azure-servicebus: opt-out via pyproject.toml mypy was effectively a no-op for this package because python_version=3.7 in mypy.ini caused mypy 1.18+ to abort immediately. Rather than fixing 61 pre-existing type errors across 4 files (unrelated to this PR), opt out of the mypy check using the standard [tool.azure-sdk-build] mechanism that many other packages already use. Revert mypy.ini to its original state. * Revert mypy=false from pyproject.toml (pre-existing CI issue, not related to this PR) * Fix mypy errors: ignore pre-existing type issues in transport modules and message init --------- Co-authored-by: Eldert Grootenboer (from Dev Box) <egrootenboer@microsoft.com>
1 parent 1563ae1 commit 2b810d5

6 files changed

Lines changed: 243 additions & 3 deletions

File tree

sdk/servicebus/azure-servicebus/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Release History
22

3-
## 7.14.4 (Unreleased)
3+
## 7.15.0 (Unreleased)
4+
5+
### Features Added
6+
7+
- Added `ServiceBusReceivedMessage.from_bytes()` classmethod to construct a `ServiceBusReceivedMessage` from raw AMQP payload bytes without requiring the deprecated `uamqp` library. ([#43979](https://github.com/Azure/azure-sdk-for-python/issues/43979))
48

59
### Bugs Fixed
610

sdk/servicebus/azure-servicebus/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ namespace azure.servicebus
299299

300300
def __str__(self) -> str: ...
301301

302+
@classmethod
303+
def from_bytes(cls, message: bytes) -> ServiceBusReceivedMessage: ...
304+
302305

303306
class azure.servicebus.ServiceBusReceiver(BaseHandler, ReceiverMixin): implements ContextManager , Iterator
304307
property client_identifier: str # Read-only
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
apiMdSha256: 0e51b69124cb208c793c257af32f007479fb277b28cf6d9b2bcb4e50cb4a5af7
1+
apiMdSha256: c5a1ed2bd1209332a754127f2464a9bdfa38a0ceaffca0a2c055b6405704224a
22
parserVersion: 0.3.28
33
pythonVersion: 3.12.10

sdk/servicebus/azure-servicebus/azure/servicebus/_common/message.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import time
1010
import warnings
1111
import datetime
12+
import struct
1213
import uuid
1314
from typing import Optional, Dict, List, Union, Iterable, Any, Mapping, cast, TYPE_CHECKING
1415

@@ -808,6 +809,54 @@ def __getstate__(self) -> Dict[str, Any]:
808809
def __setstate__(self, state: Dict[str, Any]) -> None:
809810
self.__dict__.update(state)
810811

812+
@classmethod
813+
def from_bytes(cls, message: bytes) -> "ServiceBusReceivedMessage":
814+
"""Constructs a ServiceBusReceivedMessage from the raw bytes of an AMQP message payload.
815+
816+
The message payload should adhere to the Message Format specification
817+
outlined in the AMQP v1.0 standard:
818+
http://docs.oasis-open.org/amqp/core/v1.0/os/amqp-core-messaging-v1.0-os.html#section-message-format
819+
820+
The returned message has no associated receiver. It is intended for reading
821+
message content and metadata, and cannot be used to settle the message
822+
(e.g., complete, abandon, defer, dead_letter) or renew its lock, because those
823+
operations require a live receiver.
824+
825+
Broker metadata carried in the payload is preserved. When the encoded message
826+
contains the corresponding sections, properties such as ``lock_token``,
827+
``locked_until_utc``, ``sequence_number``, and ``enqueued_time_utc`` are
828+
populated; when a section is absent, the corresponding property returns ``None``.
829+
830+
Note that AMQP value and sequence body sections, as well as some string-typed
831+
properties, are surfaced as ``bytes`` after decoding (for example, a value body
832+
of ``{"key": "value"}`` decodes to ``{b"key": b"value"}``). This reflects the
833+
raw AMQP wire encoding and is not converted to ``str``.
834+
835+
:param bytes message: The raw bytes representing the AMQP message payload.
836+
:return: A ServiceBusReceivedMessage created from the provided message payload.
837+
:rtype: ~azure.servicebus.ServiceBusReceivedMessage
838+
:raises TypeError: If ``message`` is not a ``bytes`` object.
839+
:raises ValueError: If ``message`` is empty, or is not a valid AMQP 1.0
840+
message payload (decode failures are wrapped and chained).
841+
"""
842+
from .._pyamqp._decode import decode_payload
843+
844+
if not isinstance(message, bytes):
845+
raise TypeError("message must be bytes.")
846+
if not message:
847+
raise ValueError("message cannot be empty.")
848+
849+
try:
850+
amqp_message = decode_payload(memoryview(message))
851+
except (ValueError, KeyError, IndexError, TypeError, EOFError, struct.error) as exc:
852+
raise ValueError("message is not a valid AMQP 1.0 message payload.") from exc
853+
854+
received_msg = cls(
855+
message=amqp_message,
856+
receiver=None,
857+
)
858+
return received_msg
859+
811860
@property
812861
def _lock_expired(self) -> bool:
813862
"""

sdk/servicebus/azure-servicebus/azure/servicebus/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
# Licensed under the MIT License.
44
# ------------------------------------
55

6-
VERSION = "7.14.4"
6+
VERSION = "7.15.0"

sdk/servicebus/azure-servicebus/tests/test_message.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,190 @@ def test_servicebus_message_time_to_live():
443443
assert message.time_to_live == timedelta(days=1)
444444

445445

446+
def test_servicebus_received_message_from_bytes():
447+
"""Test that from_bytes can decode a basic AMQP message payload."""
448+
from azure.servicebus._pyamqp._encode import encode_payload as _encode_payload
449+
from azure.servicebus._pyamqp.message import Message as PyamqpMessage, Header, Properties
450+
451+
# Construct a pyamqp Message with various sections
452+
original = PyamqpMessage(
453+
header=Header(durable=True, priority=4, ttl=30000, delivery_count=1),
454+
properties=Properties(
455+
message_id="test-message-id-123",
456+
content_type=b"application/json",
457+
correlation_id="corr-456",
458+
subject=b"test-subject",
459+
reply_to=b"reply-queue",
460+
group_id=b"session-1",
461+
reply_to_group_id=b"reply-session",
462+
),
463+
message_annotations={
464+
_X_OPT_PARTITION_KEY: b"pk-1",
465+
},
466+
application_properties={b"custom_prop": b"custom_value"},
467+
data=[b"hello world"],
468+
)
469+
470+
# Encode to bytes then decode via from_bytes.
471+
# _encode_payload returns the bytearray buffer it was given; wrap in bytes()
472+
# so the test exercises the public from_bytes(bytes) contract.
473+
output = bytearray()
474+
payload = bytes(_encode_payload(output, original))
475+
received = ServiceBusReceivedMessage.from_bytes(payload)
476+
477+
# Validate message body
478+
body = b"".join(received.body)
479+
assert body == b"hello world"
480+
assert received.body_type == AmqpMessageBodyType.DATA
481+
482+
# Validate properties
483+
assert received.message_id == "test-message-id-123"
484+
assert received.content_type == "application/json"
485+
assert received.correlation_id == "corr-456"
486+
assert received.subject == "test-subject"
487+
assert received.reply_to == "reply-queue"
488+
assert received.session_id == "session-1"
489+
assert received.reply_to_session_id == "reply-session"
490+
491+
# Validate header fields
492+
assert received.time_to_live == timedelta(milliseconds=30000)
493+
assert received.delivery_count == 1
494+
495+
# Validate annotations
496+
assert received.partition_key == "pk-1"
497+
498+
# Validate application properties
499+
assert received.raw_amqp_message.application_properties == {b"custom_prop": b"custom_value"}
500+
501+
# This payload carries no delivery-annotations lock token, so lock_token is None.
502+
assert received.lock_token is None
503+
504+
505+
def test_servicebus_received_message_from_bytes_minimal():
506+
"""Test from_bytes with a minimal AMQP message (data body only, no properties)."""
507+
from azure.servicebus._pyamqp._encode import encode_payload as _encode_payload
508+
from azure.servicebus._pyamqp.message import Message as PyamqpMessage
509+
510+
original = PyamqpMessage(data=[b"minimal payload"])
511+
output = bytearray()
512+
payload = bytes(_encode_payload(output, original))
513+
received = ServiceBusReceivedMessage.from_bytes(payload)
514+
515+
body = b"".join(received.body)
516+
assert body == b"minimal payload"
517+
assert received.message_id is None
518+
assert received.content_type is None
519+
assert received.session_id is None
520+
assert received.lock_token is None
521+
522+
523+
def test_servicebus_received_message_from_bytes_value_body():
524+
"""Test from_bytes with an AMQP value body message."""
525+
from azure.servicebus._pyamqp._encode import encode_payload as _encode_payload
526+
from azure.servicebus._pyamqp.message import Message as PyamqpMessage
527+
528+
original = PyamqpMessage(value={"key": "value"})
529+
output = bytearray()
530+
payload = bytes(_encode_payload(output, original))
531+
received = ServiceBusReceivedMessage.from_bytes(payload)
532+
533+
assert received.body_type == AmqpMessageBodyType.VALUE
534+
# AMQP encoding round-trips strings as bytes
535+
assert received.body == {b"key": b"value"}
536+
537+
538+
def test_servicebus_received_message_from_bytes_sequence_body():
539+
"""Test from_bytes with an AMQP sequence body message."""
540+
from azure.servicebus._pyamqp._encode import encode_payload as _encode_payload
541+
from azure.servicebus._pyamqp.message import Message as PyamqpMessage
542+
543+
original = PyamqpMessage(sequence=[1, 2, 3])
544+
output = bytearray()
545+
payload = bytes(_encode_payload(output, original))
546+
received = ServiceBusReceivedMessage.from_bytes(payload)
547+
548+
assert received.body_type == AmqpMessageBodyType.SEQUENCE
549+
# Confirm the decoded sequence contents round-trip, not just the body type.
550+
assert list(received.body) == [1, 2, 3]
551+
552+
553+
def test_servicebus_received_message_from_bytes_raises_for_none():
554+
"""Test from_bytes raises a clear TypeError for non-bytes input."""
555+
with pytest.raises(TypeError, match="message must be bytes"):
556+
ServiceBusReceivedMessage.from_bytes(None) # type: ignore[arg-type]
557+
558+
559+
def test_servicebus_received_message_from_bytes_raises_for_empty_payload():
560+
"""Test from_bytes rejects empty payloads with a clear ValueError."""
561+
with pytest.raises(ValueError, match="message cannot be empty"):
562+
ServiceBusReceivedMessage.from_bytes(b"")
563+
564+
565+
def test_servicebus_received_message_from_bytes_raises_for_invalid_payload():
566+
"""Test from_bytes wraps decode failures from malformed bytes in a chained ValueError.
567+
568+
Garbage bytes make the AMQP decoder raise low-level errors (e.g. ``TypeError`` /
569+
``IndexError``); ``from_bytes`` must surface a clear ``ValueError`` instead of
570+
leaking those, and chain the original cause for diagnosability.
571+
"""
572+
with pytest.raises(ValueError, match="not a valid AMQP") as exc_info:
573+
ServiceBusReceivedMessage.from_bytes(b"\x01\x02\x03\x04")
574+
assert exc_info.value.__cause__ is not None
575+
576+
577+
def test_servicebus_received_message_from_bytes_raises_for_truncated_payload():
578+
"""Test from_bytes wraps a truncated payload in a chained ValueError.
579+
580+
A partial read from the Functions host or a corrupted persisted message can
581+
truncate a valid payload mid-section. That lands inside the fixed-width struct
582+
decoders (e.g. ``_decode_ulong_large``), where ``unpack`` on a short slice raises
583+
``struct.error`` -- which derives from ``Exception``, not from ``IndexError``, so
584+
a slice past the buffer end returns a truncated result rather than raising.
585+
``from_bytes`` must still surface a clear chained ``ValueError`` instead of leaking
586+
the low-level ``struct.error``.
587+
"""
588+
from azure.servicebus._pyamqp._encode import encode_payload as _encode_payload
589+
from azure.servicebus._pyamqp.message import Message as PyamqpMessage, Header, Properties
590+
591+
original = PyamqpMessage(
592+
header=Header(durable=True, priority=4, ttl=30000, delivery_count=1),
593+
properties=Properties(message_id="test-message-id-123", content_type=b"application/json"),
594+
application_properties={b"custom_prop": b"custom_value"},
595+
data=[b"hello world"],
596+
)
597+
output = bytearray()
598+
payload = bytes(_encode_payload(output, original))
599+
600+
# Cut partway through a fixed-width field so unpack sees a short buffer.
601+
truncated = payload[:14]
602+
with pytest.raises(ValueError, match="not a valid AMQP") as exc_info:
603+
ServiceBusReceivedMessage.from_bytes(truncated)
604+
assert exc_info.value.__cause__ is not None
605+
606+
607+
def test_servicebus_received_message_from_bytes_preserves_broker_metadata():
608+
"""Test from_bytes preserves broker metadata carried in the payload.
609+
610+
The payload's delivery-annotations carry ``x-opt-lock-token`` and its
611+
message-annotations carry ``x-opt-sequence-number`` / ``x-opt-enqueued-time`` /
612+
``x-opt-locked-until``. Those properties must therefore be populated rather than
613+
``None``. This is a regression guard against forcing ``RECEIVE_AND_DELETE`` on
614+
reconstruction, which would mark the message settled and suppress ``lock_token``
615+
and ``locked_until_utc``.
616+
"""
617+
# Real AMQP payload captured from the Azure Functions host in issue #43979.
618+
payload = b'\x00Sp\xc0\x0b\x05@@pH\x19\x08\x00@R\x01\x00Sq\xc1$\x02\xa3\x10x-opt-lock-token\x98\xfcS\xa1_\xddfIO\x82]\xee\x1b|4<\xfb\x00Sr\xc1U\x06\xa3\x13x-opt-enqueued-time\x83\x00\x00\x01\x8ev\xc7\xdb\xc8\xa3\x15x-opt-sequence-numberU\x0c\xa3\x12x-opt-locked-until\x83\x00\x00\x01\x8ev\xc8\xc67\x00Ss\xc0?\r\xa1 f00d2a33551440389d68e299d31adc7c@@@@@@@\x83\x00\x00\x01\x8e\xbe\xe0\xe3\xc8\x83\x00\x00\x01\x8ev\xc7\xdb\xc8@@@\x00Su\xa0\x05hello'
619+
620+
received = ServiceBusReceivedMessage.from_bytes(payload)
621+
622+
assert b"".join(received.body) == b"hello"
623+
assert received.message_id == "f00d2a33551440389d68e299d31adc7c"
624+
assert received.sequence_number == 12
625+
assert received.enqueued_time_utc is not None
626+
assert received.locked_until_utc is not None
627+
assert str(received.lock_token) == "fc53a15f-dd66-494f-825d-ee1b7c343cfb"
628+
629+
446630
def _pyamqp_encoded_inner_message(body=b"data"):
447631
# Encode a minimal AMQP message payload, as the service wraps each
448632
# deferred/peeked message inside the management-link response.

0 commit comments

Comments
 (0)