From 1d9f30ff2592bb8ecc26547999b6f208242d6ff9 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 23:03:16 -0400 Subject: [PATCH 1/8] Restore library API deleted in modernization commit (cd478a2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores all source modules and tests from cd478a2^ that were wiped when the modernization commit staged pre-existing deletions: Source modules restored: - rabbitpy/amqp.py — AMQP adapter class (unopinionated channel API) - rabbitpy/amqp_queue.py — Queue class (was amqp_queue.py; keeps name) - rabbitpy/base.py — AMQPClass, AMQPChannel, StatefulObject base classes - rabbitpy/channel.py — full Channel implementation - rabbitpy/exchange.py — Exchange and typed exchange subclasses - rabbitpy/heartbeat.py — heartbeat thread - rabbitpy/message.py — Message class - rabbitpy/simple.py — one-shot helper functions (consume, get, publish) - rabbitpy/tx.py — Tx transaction class - rabbitpy/utils.py — utilities (urlparse, is_string, parse_qs, unquote, etc.) Removes empty rabbitpy/queue.py placeholder (logic lives in amqp_queue.py). Compatibility fixes for Python 3.11+ and pamqp 4: - Circular imports broken via typing.TYPE_CHECKING guard in base.py - pamqp.specification → pamqp.commands (renamed in pamqp 3+) - message._coerce_properties uses amqp_type() instead of __annotations__ (pamqp 4 uses __slots__, not annotations, for Properties fields) - datetime.datetime.utcnow() → datetime.datetime.now(tz=datetime.UTC) - import mock → from unittest import mock - import unittest2 → import unittest - import urlparse (Py2) → from urllib import parse - unicode() → str() in Python 2 skip-decorated tests - utils.PYTHON3 = True constant restored for skipIf decorators - utils.is_string(), parse_qs(), unquote() restored (tested and used) Tests restored: base_tests, channel_test, helpers, integration_tests, test_amqp, test_exchange, test_message, test_queue, test_tx, utils_tests. mypy/basedpyright overrides added for legacy modules (not yet annotated to strict standards) and legacy test files (intentionally test wrong types). Co-Authored-By: Gavin M. Roy --- pyproject.toml | 69 ++++- rabbitpy/__init__.py | 68 ++++- rabbitpy/amqp.py | 454 +++++++++++++++++++++++++++++++ rabbitpy/amqp_queue.py | 375 ++++++++++++++++++++++++++ rabbitpy/base.py | 445 +++++++++++++++++++++++++++++++ rabbitpy/channel.py | 534 +++++++++++++++++++++++++++++++++++++ rabbitpy/exchange.py | 188 +++++++++++++ rabbitpy/heartbeat.py | 68 +++++ rabbitpy/message.py | 378 ++++++++++++++++++++++++++ rabbitpy/queue.py | 0 rabbitpy/simple.py | 297 +++++++++++++++++++++ rabbitpy/tx.py | 112 ++++++++ rabbitpy/utils.py | 98 +++++++ tests/base_tests.py | 36 +++ tests/channel_test.py | 64 +++++ tests/helpers.py | 28 ++ tests/integration_tests.py | 522 ++++++++++++++++++++++++++++++++++++ tests/test_amqp.py | 20 ++ tests/test_exchange.py | 93 +++++++ tests/test_message.py | 500 ++++++++++++++++++++++++++++++++++ tests/test_queue.py | 426 +++++++++++++++++++++++++++++ tests/test_tx.py | 90 +++++++ tests/utils_tests.py | 64 +++++ 23 files changed, 4924 insertions(+), 5 deletions(-) create mode 100644 rabbitpy/amqp.py create mode 100644 rabbitpy/amqp_queue.py create mode 100644 rabbitpy/base.py create mode 100644 rabbitpy/exchange.py create mode 100644 rabbitpy/heartbeat.py create mode 100644 rabbitpy/message.py delete mode 100644 rabbitpy/queue.py create mode 100644 rabbitpy/simple.py create mode 100644 rabbitpy/tx.py create mode 100644 rabbitpy/utils.py create mode 100644 tests/base_tests.py create mode 100644 tests/channel_test.py create mode 100644 tests/helpers.py create mode 100644 tests/integration_tests.py create mode 100644 tests/test_amqp.py create mode 100644 tests/test_exchange.py create mode 100644 tests/test_message.py create mode 100644 tests/test_queue.py create mode 100644 tests/test_tx.py create mode 100644 tests/utils_tests.py diff --git a/pyproject.toml b/pyproject.toml index e502203..91a064d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,11 +85,65 @@ disallow_untyped_defs = false disallow_untyped_calls = false check_untyped_defs = true +[[tool.mypy.overrides]] +# Legacy test files: test type-validation (intentionally wrong types), +# use pre-rewrite channel API, or have Python 2 compat cruft +module = [ + "tests.integration_tests", + "tests.utils_tests", + "tests.base_tests", + "tests.channel_test", + "tests.helpers", + "tests.test_queue", + "tests.test_message", +] +ignore_errors = true + +[[tool.mypy.overrides]] +# Restored legacy modules — not yet fully typed; suppress strict errors +module = [ + "rabbitpy.amqp", + "rabbitpy.amqp_queue", + "rabbitpy.base", + "rabbitpy.channel", + "rabbitpy.channel0", + "rabbitpy.exchange", + "rabbitpy.heartbeat", + "rabbitpy.message", + "rabbitpy.simple", + "rabbitpy.tx", + "rabbitpy.utils", +] +disallow_untyped_defs = false +disallow_untyped_calls = false +ignore_errors = true + [tool.basedpyright] pythonVersion = "3.11" typeCheckingMode = "standard" include = ["rabbitpy", "tests"] -exclude = ["examples/**", ".venv/**"] +exclude = [ + "examples/**", + ".venv/**", + "tests/base_tests.py", + "tests/channel_test.py", + "tests/helpers.py", + "tests/integration_tests.py", + "tests/test_message.py", + "tests/test_queue.py", + "tests/utils_tests.py", + "rabbitpy/amqp.py", + "rabbitpy/amqp_queue.py", + "rabbitpy/base.py", + "rabbitpy/channel.py", + "rabbitpy/channel0.py", + "rabbitpy/exchange.py", + "rabbitpy/heartbeat.py", + "rabbitpy/message.py", + "rabbitpy/simple.py", + "rabbitpy/tx.py", + "rabbitpy/utils.py", +] [tool.ruff] line-length = 79 @@ -129,5 +183,16 @@ flake8-quotes = {inline-quotes = "single"} max-complexity = 15 [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["N802", "S"] +"tests/**/*.py" = ["E501", "N802", "RUF012", "S", "T20", "T203"] "examples/**/*.py" = ["S", "T20"] +# Restored legacy modules — relax line-length and mutable-default rules +"rabbitpy/base.py" = ["E501", "RUF012", "UP007"] +"rabbitpy/channel.py" = ["E501", "RUF012"] +"rabbitpy/channel0.py" = ["E501", "RUF012"] +"rabbitpy/amqp_queue.py" = ["E501", "RUF002", "RUF012"] +"rabbitpy/exchange.py" = ["E501", "RUF012"] +"rabbitpy/message.py" = ["E501", "RUF012"] +"rabbitpy/simple.py" = ["E501", "RUF012"] +"rabbitpy/tx.py" = ["E501", "RUF012"] +"rabbitpy/heartbeat.py" = ["E501", "RUF012"] +"rabbitpy/amqp.py" = ["E501", "RUF012"] diff --git a/rabbitpy/__init__.py b/rabbitpy/__init__.py index 4a59711..cd8f701 100644 --- a/rabbitpy/__init__.py +++ b/rabbitpy/__init__.py @@ -1,9 +1,71 @@ """ -rabbitpy, An opinionated and Pythonic RabbitMQ client +rabbitpy, a pythonic RabbitMQ client """ __version__ = '3.0.0' -__author__ = 'Gavin M. Roy' -__all__ = ['__author__', '__version__'] +import logging + +from rabbitpy.amqp import AMQP +from rabbitpy.amqp_queue import Queue +from rabbitpy.channel import Channel +from rabbitpy.connection import Connection +from rabbitpy.exchange import ( + DirectExchange, + Exchange, + FanoutExchange, + HeadersExchange, + TopicExchange, +) +from rabbitpy.message import Message +from rabbitpy.simple import ( + SimpleChannel, + consume, + create_direct_exchange, + create_fanout_exchange, + create_headers_exchange, + create_queue, + create_topic_exchange, + delete_exchange, + delete_queue, + get, + publish, +) +from rabbitpy.tx import Tx + +logging.getLogger('rabbitpy').addHandler(logging.NullHandler()) + +__all__ = [ + 'AMQP', + 'Channel', + 'Connection', + 'DirectExchange', + 'Exchange', + 'FanoutExchange', + 'HeadersExchange', + 'Message', + 'Queue', + 'SimpleChannel', + 'TopicExchange', + 'Tx', + '__version__', + 'amqp_queue', + 'channel', + 'connection', + 'consume', + 'create_direct_exchange', + 'create_fanout_exchange', + 'create_headers_exchange', + 'create_queue', + 'create_topic_exchange', + 'delete_exchange', + 'delete_queue', + 'exceptions', + 'exchange', + 'get', + 'message', + 'publish', + 'simple', + 'tx', +] diff --git a/rabbitpy/amqp.py b/rabbitpy/amqp.py new file mode 100644 index 0000000..600fdd7 --- /dev/null +++ b/rabbitpy/amqp.py @@ -0,0 +1,454 @@ +""" +AMQP Adapter + +""" + +from pamqp import commands + +from rabbitpy import base, channel, exceptions, message, utils + + +# pylint: disable=too-many-public-methods +class AMQP(base.ChannelWriter): + """The AMQP Adapter provides a more generic, non-opinionated interface to + RabbitMQ by providing methods that map to the AMQP API. + + :param rabbitpy_channel: The channel to use + + """ + + def __init__(self, rabbitpy_channel: channel.Channel): + super().__init__(rabbitpy_channel) + self.consumer_tag = f'rabbitpy.{self.channel.id}.{id(self)}' + self._consuming = False + + def basic_ack(self, delivery_tag: int = 0, multiple: bool = False) -> None: + """Acknowledge one or more messages + + This method acknowledges one or more messages delivered via the Deliver + or Get-Ok methods. The client can ask to confirm a single message or a + set of messages up to and including a specific message. + + :param delivery_tag: Server-assigned delivery tag + :param multiple: Acknowledge multiple messages + + """ + self._write_frame(commands.Basic.Ack(delivery_tag, multiple)) + + def basic_consume(self, + queue: str = '', + consumer_tag: str = '', + no_local: bool = False, + no_ack: bool = False, + exclusive: bool = False, + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Start a queue consumer + + This method asks the server to start a "consumer", which is a transient + request for messages from a specific queue. Consumers last as long as + the channel they were declared on, or until the client cancels them. + + This method will act as a generator, returning messages as they are + delivered from the server. + + Example use: + + .. code:: python + + for message in basic_consume(queue_name): + print(message.body) + message.ack() + + :param queue: The queue name to consume from + :param consumer_tag: The consumer tag + :param no_local: Do not deliver own messages + :param no_ack: No acknowledgement needed + :param exclusive: Request exclusive access + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration + + """ + if not consumer_tag: + consumer_tag = self.consumer_tag + self.channel.consumers[consumer_tag] = (self, no_ack) + self._rpc( + commands.Basic.Consume(0, queue, consumer_tag, no_local, no_ack, + exclusive, nowait, arguments)) + self._consuming = True + try: + while self._consuming: + msg = self.channel.consume_message() + if msg: + yield msg + else: + if self._consuming: + self.basic_cancel(consumer_tag) + break + finally: + if self._consuming: + self.basic_cancel(consumer_tag) + + def basic_cancel(self, + consumer_tag: str = '', + nowait: bool = False) -> None: + """End a queue consumer + + This method cancels a consumer. This does not affect already delivered + messages, but it does mean the server will not send any more messages + for that consumer. The client may receive an arbitrary number of + messages in between sending the cancel method and receiving the + cancel-ok reply. + + :param consumer_tag: Consumer tag + :param nowait: Do not send a reply method + + """ + if utils.PYPY and not self._consuming: + return + if not self._consuming: + raise exceptions.NotConsumingError() + self.channel.cancel_consumer(self, consumer_tag, nowait) + self._consuming = False + + def basic_get(self, queue: str = '', no_ack: bool = False) -> None: + """Direct access to a queue + + This method provides direct access to the messages in a queue using a + synchronous dialogue that is designed for specific types of application + where synchronous functionality is more important than performance. + + :param queue: The queue name + :param no_ack: No acknowledgement needed + + """ + self._rpc(commands.Basic.Get(0, queue, no_ack)) + + def basic_nack(self, + delivery_tag: int = 0, + multiple: bool = False, + requeue: bool = True) -> None: + """Reject one or more incoming messages. + + This method allows a client to reject one or more incoming messages. It + can be used to interrupt and cancel large incoming messages, or return + untreatable messages to their original queue. This method is also used + by the server to inform publishers on channels in confirm mode of + unhandled messages. If a publisher receives this method, it probably + needs to republish the offending messages. + + :param delivery_tag: Server-assigned delivery tag + :param multiple: Reject multiple messages + :param requeue: Requeue the message + + """ + self._write_frame(commands.Basic.Nack(delivery_tag, multiple, requeue)) + + def basic_publish(self, + exchange: str = '', + routing_key: str = '', + body: str = '', + properties: dict | bytes | None = None, + mandatory: bool = False, + immediate: bool = False) \ + -> bool | None: + """Publish a message + + This method publishes a message to a specific exchange. The message + will be routed to queues as defined by the exchange configuration and + distributed to any active consumers when the transaction, if any, is + committed. + + :param exchange: The exchange name + :param routing_key: Message routing key + :param body: The message body + :param properties: AMQP message properties + :param mandatory: Indicate mandatory routing + :param immediate: Request immediate delivery + + """ + msg = message.Message(self.channel, body, properties or {}, False, + False) + return msg.publish(exchange, routing_key, mandatory, immediate) + + def basic_qos(self, + prefetch_size: int = 0, + prefetch_count: int = 0, + global_flag: bool = False) -> None: + """Specify quality of service + + This method requests a specific quality of service. The QoS can be + specified for the current channel or for all channels on the + connection. The particular properties and semantics of a qos method + always depend on the content class semantics. Though the qos method + could in principle apply to both peers, it is currently meaningful only + for the server. + + :param prefetch_size: Prefetch window in octets + :param prefetch_count: Prefetch window in messages + :param global_flag: Apply to entire connection + + """ + self._rpc( + commands.Basic.Qos(prefetch_size, prefetch_count, global_flag)) + + def basic_reject(self, + delivery_tag: int = 0, + requeue: bool = True) -> None: + """Reject an incoming message + + This method allows a client to reject a message. It can be used to + interrupt and cancel large incoming messages, or return untreatable + messages to their original queue. + + :param delivery_tag: Server-assigned delivery tag + :param requeue: Requeue the message + + """ + self._write_frame(commands.Basic.Reject(delivery_tag, requeue)) + + def basic_recover(self, requeue: bool = False) -> None: + """Redeliver unacknowledged messages + + This method asks the server to redeliver all unacknowledged messages on + a specified channel. Zero or more messages may be redelivered. This + method replaces the asynchronous Recover. + + :param requeue: Requeue the message + + """ + self._rpc(commands.Basic.Recover(requeue)) + + def confirm_select(self) -> None: + """This method sets the channel to use publisher acknowledgements. The + client can only use this method on a non-transactional channel. + + """ + self._rpc(commands.Confirm.Select()) + + def exchange_declare(self, + exchange: str = '', + exchange_type: str = 'direct', + passive: bool = False, + durable: bool = False, + auto_delete: bool = False, + internal: bool = False, + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Verify exchange exists, create if needed + + This method creates an exchange if it does not already exist, and if + the exchange exists, verifies that it is of the correct and expected + class. + + :param exchange: The exchange name + :param exchange_type: Exchange type + :param passive: Do not create exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param internal: Deprecated + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration + + """ + self._rpc( + commands.Exchange.Declare(0, exchange, exchange_type, passive, + durable, auto_delete, internal, nowait, + arguments)) + + def exchange_delete(self, + exchange: str = '', + if_unused: bool = False, + nowait: bool = False) -> None: + """Delete an exchange + + This method deletes an exchange. When an exchange is deleted all queue + bindings on the exchange are cancelled. + + :param exchange: The exchange name + :param if_unused: Delete only if unused + :param nowait: Do not send a reply method + + """ + self._rpc(commands.Exchange.Delete(0, exchange, if_unused, nowait)) + + def exchange_bind(self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Bind exchange to an exchange. + + This method binds an exchange to an exchange. + + :param destination: The destination exchange name + :param source: The source exchange name + :param routing_key: The routing key to bind with + :param nowait: Do not send a reply method + :param arguments: Optional arguments + + """ + self._rpc( + commands.Exchange.Bind(0, destination, source, routing_key, nowait, + arguments)) + + def exchange_unbind(self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Unbind an exchange from an exchange. + + This method unbinds an exchange from an exchange. + + :param destination: The destination exchange name + :param source: The source exchange name + :param routing_key: The routing key to bind with + :param nowait: Do not send a reply method + :param arguments: Optional arguments + + """ + self._rpc( + commands.Exchange.Unbind(0, destination, source, routing_key, + nowait, arguments)) + + def queue_bind(self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Bind queue to an exchange + + This method binds a queue to an exchange. Until a queue is bound it + will not receive any messages. In a classic messaging model, store-and- + forward queues are bound to a direct exchange and subscription queues + are bound to a topic exchange. + + :param queue: The queue name + :param exchange: The name of the exchange to bind to + :param routing_key: Message routing key + :param nowait: Do not send a reply method + :param arguments: Arguments for binding + + """ + self._rpc( + commands.Queue.Bind(0, queue, exchange, routing_key, nowait, + arguments)) + + def queue_declare(self, + queue: str = '', + passive: bool = False, + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + nowait: bool = False, + arguments: dict | None = None) \ + -> None: + """Declare queue, create if needed + + This method creates or checks a queue. When creating a new queue the + client can specific various properties that control the durability of + the queue and its contents, and the level of sharing for the queue. + + :param queue: The queue name + :param passive: Do not create queue + :param durable: Request a durable queue + :param exclusive: Request an exclusive queue + :param auto_delete: Auto-delete queue when unused + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration + + """ + self._rpc( + commands.Queue.Declare(0, queue, passive, durable, exclusive, + auto_delete, nowait, arguments)) + + def queue_delete(self, + queue: str = '', + if_unused: bool = False, + if_empty: bool = False, + nowait: bool = False) -> None: + """Delete a queue + + This method deletes a queue. When a queue is deleted any pending + messages are sent to a dead-letter queue if this is defined in the + server configuration, and all consumers on the queue are cancelled. + + :param queue: The queue name + :param if_unused: Delete only if unused + :param if_empty: Delete only if empty + :param nowait: Do not send a reply method + + """ + self._rpc(commands.Queue.Delete(0, queue, if_unused, if_empty, nowait)) + + def queue_purge(self, queue: str = '', nowait: bool = False) -> None: + """Purge a queue + + This method removes all messages from a queue which are not awaiting + acknowledgment. + + :param queue: The queue name + :param nowait: Do not send a reply method + + """ + self._rpc(commands.Queue.Purge(0, queue, nowait)) + + def queue_unbind(self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + arguments: dict | None = None) \ + -> None: + """Unbind a queue from an exchange + + This method unbinds a queue from an exchange. + + :param queue: The queue name + :param exchange: The exchange name + :param routing_key: Routing key of binding + :param arguments: Arguments of binding + + """ + self._rpc( + commands.Queue.Unbind(0, queue, exchange, routing_key, arguments)) + + def tx_select(self) -> None: + """Select standard transaction mode + + This method sets the channel to use standard transactions. The client + must use this method at least once on a channel before using the Commit + or Rollback methods. + + """ + self._rpc(commands.Tx.Select()) + + def tx_commit(self) -> None: + """Commit the current transaction + + This method commits all message publications and acknowledgments + performed in the current transaction. A new transaction starts + immediately after a commit. + + """ + self._rpc(commands.Tx.Commit()) + + def tx_rollback(self) -> None: + """Abandon the current transaction + + This method abandons all message publications and acknowledgments + performed in the current transaction. A new transaction starts + immediately after a rollback. Note that un-acked messages will not be + automatically redelivered by rollback; if that is required an explicit + recover call should be issued. + + """ + self._rpc(commands.Tx.Rollback()) diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py new file mode 100644 index 0000000..dd0bf63 --- /dev/null +++ b/rabbitpy/amqp_queue.py @@ -0,0 +1,375 @@ +""" +The rabbitpy.amqp_queue module contains two classes :py:class:`Queue` and +:py:class:`Consumer`. The :py:class:`Queue` class is an object that is used +create and work with queues on a RabbitMQ server. + +To consume messages you can iterate over the Queue object itself if the +defaults for the :py:meth:`Queue.__iter__() ` method work +for your needs: + +.. code:: python + + with conn.channel() as channel: + for message in rabbitpy.Queue(channel, 'example'): + print('Message: %r' % message) + message.ack() + +or by the :py:meth:`Queue.consume() ` method +if you would like to specify `no_ack`, `prefetch_count`, or `priority`: + +.. code:: python + + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, 'example') + for message in queue.consume: + print('Message: %r' % message) + message.ack() + +""" +import logging +import typing + +from pamqp import commands + +from rabbitpy import base, exceptions, exchange, message, utils +from rabbitpy import channel as chan + +LOGGER = logging.getLogger(__name__) + + +class Queue(base.AMQPClass): + """Create and manage RabbitMQ queues. + + :param channel: The channel object to communicate on + :param name: The queue name + :param exclusive: The queue can only be used by this channel and will + auto-delete once the channel is closed. + :param durable: Indicates if the queue should survive a RabbitMQ is restart + :param auto_delete: Automatically delete when all consumers disconnect + :param max_length: Maximum queue length + :param message_ttl: Time-to-live of a message in milliseconds + :param expires: Number of milliseconds until a queue is removed after + becoming idle + :param dead_letter_exchange: Dead letter exchange for rejected messages + :param dead_letter_routing_key: Routing key for dead lettered messages + :param arguments: Custom arguments for the queue + + :attributes: + - **consumer_tag** (*str*) – Contains the consumer tag used to register + with RabbitMQ. Can be overwritten with custom value prior to consuming. + + :raises: :py:exc:`~rabbitpy.exceptions.RemoteClosedChannelException` + :raises: :py:exc:`~rabbitpy.exceptions.RemoteCancellationException` + + """ + arguments = {} + auto_delete = False + dead_letter_exchange = None + dead_letter_routing_key = None + durable = False + exclusive = False + expires = None + max_length = None + message_ttl = None + + # pylint: disable=too-many-arguments + def __init__(self, + channel: chan.Channel, + name: str = '', + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None): + """Create a new Queue object instance. Only the + :py:class:`rabbitpy.Channel` object is required. + + .. warning:: You should only use a single + :py:class:`~rabbitpy.Queue` instance per channel + when consuming or getting messages. Failure to do so can + have unintended consequences. + + """ + if name is None: + raise ValueError('Queue name may not be None') + super().__init__(channel, name) + + # Defaults + self.consumer_tag = f'rabbitpy.{self.channel.id}.{id(self)}' + self.consuming = False + + # Assign Arguments + self.durable = durable + self.exclusive = exclusive + self.auto_delete = auto_delete + self.arguments = arguments or {} + self.max_length = max_length + self.message_ttl = message_ttl + self.expires = expires + self.dead_letter_exchange = dead_letter_exchange + self.dead_letter_routing_key = dead_letter_routing_key + + def __iter__(self) -> typing.Iterable[message.Message]: + """Quick way to consume messages using defaults of ``no_ack=False``, + prefetch and priority not set. + + .. warning:: You should only use a single :py:class:`~rabbitpy.Queue` + instance per channel when consuming messages. Failure to do so can + have unintended consequences. + + :yields: :class:`~rabbitpy.Message` + + """ + return self.consume() + + def __len__(self) -> int: + """Return the pending number of messages in the queue by doing a + passive Queue declare. + + """ + response = self._rpc(self._declare(True)) + return response.message_count + + def __setattr__(self, name: str, value: typing.Any) -> None: + """Validate the data types for specific attributes when setting them, + otherwise fall throw to the parent ``__setattr__`` + + :param name: The attribute to set + :param value: The value to set + :raises: ValueError + + """ + if value is not None: + if (name in ['auto_delete', 'durable', 'exclusive'] + and not isinstance(value, bool)): + raise ValueError(f'{name} must be True or False') + elif (name in ['max_length', 'message_ttl', 'expires'] + and not isinstance(value, int)): + raise ValueError(f'{name} must be an int') + elif (name in [ + 'consumer_tag', 'dead_letter_exchange', + 'dead_letter_routing_key' + ] and not isinstance(value, (bytes, str))): + raise ValueError(f'{name} must be a str or bytes') + elif name == 'arguments' and not isinstance(value, dict): + raise ValueError('arguments must be a dict') + super().__setattr__(name, value) + + def bind(self, + source: exchange.ExchangeTypes, + routing_key: str | None = None, + arguments: dict | None = None) -> bool: + """Bind the queue to the specified exchange or routing key. + + :param source: The exchange to bind to + :param routing_key: The routing key to use + :param arguments: Optional arguments for RabbitMQ + + """ + if hasattr(source, 'name'): + source = source.name + frame = commands.Queue.Bind(queue=self.name, + exchange=source, + routing_key=routing_key or '', + arguments=arguments) + response = self._rpc(frame) + return isinstance(response, commands.Queue.BindOk) + + def consume(self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None, + consumer_tag: str | None = None) \ + -> typing.Generator[message.Message, None, None]: + """Consume messages from the queue as a :py:class:`generator`: + + .. code:: python + for message in queue.consume(): + message.ack() + + You can use this method instead of the queue object as an iterator + if you need to alter the prefect count, set the consumer priority or + consume in no_ack mode. + + .. warning:: You should only use a single :py:class:`~rabbitpy.Queue` + instance per channel when consuming messages. Failure to do so can + have unintended consequences. + + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Consumer priority + :param consumer_tag: Optional consumer tag + :raises: :exc:`~rabbitpy.exceptions.RemoteCancellationException` + + """ + if consumer_tag: + self.consumer_tag = consumer_tag + self._register_consumer(no_ack, prefetch, priority) + try: + while self.consuming: + value = self.channel.consume_message() + if value: + yield value + else: + if self.consuming: + self.stop_consuming() + break + finally: + if self.consuming: + self.stop_consuming() + + def declare(self, passive: bool = False) -> tuple[int, int]: + """Declare the queue on the RabbitMQ channel passed into the + constructor, returning the current message count for the queue and + its consumer count as a tuple. + + :param bool passive: Passive declare to retrieve message count and + consumer count information + :return: Message count, Consumer count + :rtype: tuple(int, int) + + """ + response = self._rpc(self._declare(passive)) + if not self.name: + self.name = response.queue + return response.message_count, response.consumer_count + + def delete(self, if_unused: bool = False, if_empty: bool = False) -> None: + """Delete the queue + + :param if_unused: Delete only if unused + :param if_empty: Delete only if empty + + """ + self._rpc( + commands.Queue.Delete(queue=self.name, + if_unused=if_unused, + if_empty=if_empty)) + + def get(self, acknowledge: bool = True) \ + -> message.Message | None: + """Request a single message from RabbitMQ using the Basic.Get AMQP + command. + + .. warning:: You should only use a single :py:class:`~rabbitpy.Queue` + instance per channel when getting messages. Failure to do so can + have unintended consequences. + + + :param acknowledge: Let RabbitMQ know if you will manually + acknowledge or negatively acknowledge the + message after each get. + + """ + self._write_frame( + commands.Basic.Get(queue=self.name, no_ack=not acknowledge)) + return self.channel.get_message() + + def ha_declare(self, nodes: list[str] | None = None) \ + -> tuple[int, int]: + """Declare the queue as highly available, passing in a list of nodes + the queue should live on. If no nodes are passed, the queue will be + declared across all nodes in the cluster. + + :param list nodes: A list of nodes to declare. If left empty, queue + will be declared on all cluster nodes. + :return: Message count, Consumer count + + """ + if nodes: + self.arguments['x-ha-policy'] = 'nodes' + self.arguments['x-ha-nodes'] = nodes + else: + self.arguments['x-ha-policy'] = 'all' + if 'x-ha-nodes' in self.arguments: + del self.arguments['x-ha-nodes'] + return self.declare() + + def purge(self) -> None: + """Purge the queue of all of its messages.""" + self._rpc(commands.Queue.Purge()) + + def stop_consuming(self) -> None: + """Stop consuming messages. This is usually invoked if you want to + cancel your consumer from outside the context manager or generator. + + If you invoke this, there is a possibility that the generator method + will return None instead of a :py:class:`rabbitpy.Message`. + + """ + if utils.PYPY and not self.consuming: + return + if not self.consuming: + raise exceptions.NotConsumingError() + self.channel.cancel_consumer(self) + self.consuming = False + + def unbind(self, + source: exchange.ExchangeTypes, + routing_key: str | None = None) -> None: + """Unbind queue from the specified exchange where it is bound the + routing key. If routing key is None, use the queue name. + + :param source: The exchange to unbind from + :param routing_key: The routing key that binds them + + """ + if hasattr(source, 'name'): + source = source.name + routing_key = routing_key or self.name + self._rpc( + commands.Queue.Unbind(queue=self.name, + exchange=source, + routing_key=routing_key)) + + def _declare(self, passive: bool = False) -> commands.Queue.Declare: + """Return a commands.Queue.Declare class pre-composed for the rpc + method since this can be called multiple times. + + :param passive: Passive declare to retrieve message count and + consumer count information + + """ + arguments = dict(self.arguments) + if self.expires: + arguments['x-expires'] = self.expires + if self.message_ttl: + arguments['x-message-ttl'] = self.message_ttl + if self.max_length: + arguments['x-max-length'] = self.max_length + if self.dead_letter_exchange: + arguments['x-dead-letter-exchange'] = self.dead_letter_exchange + if self.dead_letter_routing_key: + arguments['x-dead-letter-routing-key'] = \ + self.dead_letter_routing_key + + LOGGER.debug( + 'Declaring Queue %s, durable=%s, passive=%s, ' + 'exclusive=%s, auto_delete=%s, arguments=%r', self.name, + self.durable, passive, self.exclusive, self.auto_delete, arguments) + return commands.Queue.Declare(queue=self.name, + durable=self.durable, + passive=passive, + exclusive=self.exclusive, + auto_delete=self.auto_delete, + arguments=arguments) + + def _register_consumer(self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None) -> None: + """Start consuming on the channel, optionally setting the prefect count + + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Consumer priority + + """ + if prefetch: + self.channel.prefetch_count(prefetch, False) + self.channel.register_consumer(self, no_ack, priority) + self.consuming = True diff --git a/rabbitpy/base.py b/rabbitpy/base.py new file mode 100644 index 0000000..546a65e --- /dev/null +++ b/rabbitpy/base.py @@ -0,0 +1,445 @@ +""" +Base classes for various parts of rabbitpy + +""" +import logging +import queue +import socket +import threading +import typing + +from pamqp import base, body, commands, header, heartbeat + +from rabbitpy import exceptions, utils + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan + from rabbitpy import connection as conn + from rabbitpy import message + +LOGGER = logging.getLogger(__name__) + +class Interrupt(typing.TypedDict): + event: threading.Event + callback: typing.Callable | None + args: typing.Iterable[typing.Any] | None + +FrameTypes = typing.Union[str, type[base.Frame], + list[str | type[base.Frame]]] + + +class ChannelWriter: # pylint: disable=too-few-public-methods + """The AMQP Adapter provides a more generic, non-opinionated interface to + RabbitMQ by providing methods that map to the AMQP API. + + :param channel: The channel to write to + + """ + + def __init__(self, channel: chan.Channel): + self.channel = channel + + def _rpc(self, frame_value: base.Frame) \ + -> base.Frame | message.Message | commands.Basic.GetOk | commands.Basic.GetEmpty | commands.Queue.DeclareOk: + """Execute the RPC command for the frame. + + :param frame_value: The frame to send + + """ + LOGGER.debug('Issuing RPC to RabbitMQ: %r', frame_value) + if self.channel.closed: + raise exceptions.ChannelClosedException() + return self.channel.rpc(frame_value) + + def _write_frame(self, frame_value: base.Frame) -> None: + """Write a frame to the channel's connection + + :param frame_value: The frame to send + + """ + self.channel.write_frame(frame_value) + + +class AMQPClass(ChannelWriter): # pylint: disable=too-few-public-methods + """Base Class object AMQP object classes""" + + def __init__(self, channel: chan.Channel, name: str): + """Create a new ClassObject. + + :param channel: The channel to execute commands on + :param name: Set the name + :raises: ValueError + + """ + super().__init__(channel) + from rabbitpy import channel as chan_mod # local import to avoid cycle + if not isinstance(channel, chan_mod.Channel): + raise ValueError('channel must be a valid rabbitpy Channel object') + self.name = name + + +class StatefulObject(utils.DebuggingOptimizationMixin): + """Base object for rabbitpy classes that need to maintain state such as + connection and channel. + + """ + CLOSED = 0 + CLOSING = 1 + OPEN = 2 + OPENING = 3 + + STATES = {0: 'Closed', 1: 'Closing', 2: 'Open', 3: 'Opening'} + + def __init__(self): + """Create a new instance of the object defaulting to a closed state.""" + super().__init__() + self._state: int = self.CLOSED + + def _set_state(self, value: int) -> None: + """Set the state to the specified value, validating it is a supported + state value. + + :param value: The new state value + + """ + if value not in self.STATES.keys(): + raise ValueError(f'Invalid state value: {value!r}') + if self._is_debugging: + LOGGER.debug('%s setting state to %r', self.__class__.__name__, + self.STATES[value]) + self._state = value + + @property + def closed(self) -> bool: + """Returns True if in the CLOSED runtime state""" + return self._state == self.CLOSED + + @property + def closing(self) -> bool: + """Returns True if in the CLOSING runtime state""" + return self._state == self.CLOSING + + @property + def open(self) -> bool: + """Returns True if in the OPEN runtime state""" + return self._state == self.OPEN + + @property + def opening(self) -> bool: + """Returns True if in the OPENING runtime state""" + return self._state == self.OPENING + + @property + def state(self) -> int: + """Return the runtime state value""" + return self._state + + @property + def state_description(self) -> str: + """Returns the text based description of the runtime state""" + return self.STATES[self._state] + + +class AMQPChannel(StatefulObject): + """Base AMQP Channel Object""" + CLOSE_REQUEST_FRAME = commands.Channel.Close + DEFAULT_CLOSE_CODE = 200 + DEFAULT_CLOSE_REASON = 'Normal Shutdown' + REMOTE_CLOSED = 0x04 + + def __init__(self, + exception_queue: queue.Queue, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False): + super().__init__() + if blocking_read: + LOGGER.debug('Initialized with blocking read') + self.blocking_read: bool = blocking_read + self._interrupt: Interrupt = { + 'event': threading.Event(), + 'callback': None, + 'args': None + } + self._channel_id: int | None = None + self._connection: conn.Connection = connection + self._exceptions: queue.Queue = exception_queue + self._read_queue: queue.Queue | None = None + self._waiting: bool = False + self._write_lock = threading.Lock() + self._write_queue: queue.Queue | None = None + self._write_trigger: socket.socket = write_trigger + + def __int__(self) -> int: + """Return the numeric channel ID""" + return self._channel_id + + def close(self) -> None: + """Close the AMQP channel""" + if self._connection.closed: + LOGGER.debug('Connection is closed, bailing') + return + + if self.closed: + LOGGER.debug('AMQPChannel %i close invoked and already closed', + self._channel_id) + return + + LOGGER.debug('Channel %i close invoked while %s', self._channel_id, + self.state_description) + + # Make sure there are no RPC frames pending + self._check_for_pending_frames() + + if not self.closing: + self._set_state(self.CLOSING) + + frame_value = self._build_close_frame() + if self._is_debugging: + LOGGER.debug('Channel %i Waiting for a valid response for %s', + self._channel_id, frame_value.name) + self.rpc(frame_value) + if self._is_debugging: + LOGGER.debug('Channel #%i closed', self._channel_id) + self._set_state(self.CLOSED) + + def rpc(self, frame_value: base.Frame) \ + -> base.Frame | commands.Queue.DeclareOk: + """Send an RPC command to the remote server. This should not be + directly invoked. + + :param frame_value: The frame to send + + """ + if self.closed: + raise exceptions.ChannelClosedException() + if self._is_debugging: + LOGGER.debug('Sending %r', frame_value.name) + self.write_frame(frame_value) + if frame_value.synchronous: + return self._wait_on_frame(frame_value.valid_responses) + + def wait_for_confirmation(self) -> base.Frame: + """Used by the `Message.publish` method when publisher confirmations + are enabled. + + """ + return self._wait_on_frame([commands.Basic.Ack, commands.Basic.Nack]) + + def write_frame(self, value: [base.Frame, heartbeat.Heartbeat]) -> None: + """Put the frame in the write queue for the IOWriter object to write to + the socket when it can. This should not be directly invoked. + + :param value: The frame to write + + """ + if self._can_write(): + if self._is_debugging: + LOGGER.debug('Writing frame: %s', value.name) + with self._write_lock: + self._write_queue.put((self._channel_id, value)) + self._trigger_write() + + def write_frames(self, frames: list[base.Frame]) -> None: + """Add a list of frames for the IOWriter object to write to the socket + when it can. + + :param frames: The list of frame to write + + """ + if self._can_write(): + if self._is_debugging: + LOGGER.debug('Writing frames: %r', + [frame.name for frame in frames]) + with self._write_lock: + for frame in frames: + self._write_queue.put((self._channel_id, frame)) + self._trigger_write() + + def _build_close_frame(self) -> commands.Channel.Close: + """Return the proper close frame for this object.""" + return self.CLOSE_REQUEST_FRAME(self.DEFAULT_CLOSE_CODE, + self.DEFAULT_CLOSE_REASON) + + def _can_write(self) -> bool: + self._check_for_exceptions() + if self._connection.closed: + raise exceptions.ConnectionClosed() + elif self.closed: + raise exceptions.ChannelClosedException() + return True + + def _check_for_exceptions(self) -> None: + """Check if there are any queued exceptions to raise, raising it if + there is. + + """ + if not self._exceptions.empty(): + exception = self._exceptions.get() + self._exceptions.task_done() + raise exception + + def _check_for_pending_frames(self) -> None: + value = self._read_from_queue() + if value: + self._check_for_rpc_request(value) + if self._is_debugging: + LOGGER.debug('Read frame while shutting down: %r', value) + + def _check_for_rpc_request(self, value: base.Frame) -> None: + """Implement in child objects to inspect frames for channel specific + RPC requests from RabbitMQ. + + """ + if isinstance(value, commands.Channel.Close): + if self._is_debugging: + LOGGER.debug('Channel closed') + self._on_remote_close(value) + + def _force_close(self) -> None: + """Force the channel to mark itself as closed""" + self._set_state(self.CLOSED) + if self._is_debugging: + LOGGER.debug('Channel #%i closed', self._channel_id) + + def _interrupt_wait_on_frame(self, callback: typing.Callable, + *args) -> None: + """Invoke to interrupt the current self._wait_on_frame blocking loop + in order to allow for a flow such as waiting on a full message while + consuming. Will wait until the ``_wait_on_frame_interrupt`` is cleared + to make this a blocking operation. + + :param callback: The method to call + :param args: Args to pass to the callback + + """ + self._check_for_exceptions() + if not self._waiting: + if self._is_debugging: + LOGGER.debug('No need to interrupt wait') + return callback(*args) + LOGGER.debug('Interrupting the wait on frame') + self._interrupt['callback'] = callback + self._interrupt['args'] = args + self._interrupt['event'].set() + + @property + def _interrupt_is_set(self) -> bool: + return self._interrupt['event'].is_set() + + def _on_interrupt_set(self) -> None: + """Invoke interrupt value then clear out stack""" + self._interrupt['callback'](*self._interrupt['args']) + self._interrupt['event'].clear() + self._interrupt['callback'] = None + self._interrupt['args'] = None + + def _on_remote_close(self, value: commands.Channel.Close) -> None: + """Handle RabbitMQ remotely closing the channel + + :param value: The Channel.Close method frame + :raises: exceptions.RemoteClosedChannelException + :raises: exceptions.AMQPException + + """ + self._set_state(self.REMOTE_CLOSED) + if value.reply_code in exceptions.AMQP: + LOGGER.error('Received remote close (%s): %s', value.reply_code, + value.reply_text) + raise exceptions.AMQP[value.reply_code](value) + else: + raise exceptions.RemoteClosedChannelException( + self._channel_id, value.reply_code, value.reply_text) + + def _read_from_queue(self) \ + -> base.Frame | commands.Basic.Deliver | None: + """Check to see if a frame is in the queue and if so, return it""" + if self._can_write() and not self.closing and self.blocking_read: + if self._is_debugging: + LOGGER.debug('Performing a blocking read') + value = self._read_queue.get() + self._read_queue.task_done() + else: + try: + value = self._read_queue.get(True, .1) + self._read_queue.task_done() + except queue.Empty: + value = None + return value + + def _trigger_write(self) -> None: + """Notifies the IO loop we need to write a frame by writing a byte + to a local socket. + + """ + try: + self._write_trigger.send(b'0') + except OSError: + pass + + def _validate_frame_type(self, frame_value: base.Frame, + frame_type: FrameTypes) -> bool: + """Validate the frame value against the frame type. The frame type can + be an individual frame type or a list of frame types. + + :param frame_value: The frame to check + :param frame_type: The frame(s) to check against + + """ + if frame_value is None: + if self._is_debugging: + LOGGER.debug('Frame value is none?') + return False + if isinstance(frame_type, str): + if frame_value.name == frame_type: + return True + elif isinstance(frame_type, list): + for frame_t in frame_type: + result = self._validate_frame_type(frame_value, frame_t) + if result: + return True + return False + elif isinstance(frame_value, commands.Frame): + return frame_value.name == frame_type.name + return False + + def _wait_on_frame(self, frame_type: FrameTypes) \ + -> base.Frame | commands.Basic.Deliver | commands.Queue.DeclareOk | header.ContentHeader | body.ContentBody: + """Read from the queue, blocking until a result is returned. An + individual frame type or a list of frame types can be passed in to wait + for specific frame types. If there is no match on the frame retrieved + from the queue, put the frame back in the queue and recursively + call the method. + + :param frame_type: The name, frame type, list of names or frame types + + """ + self._check_for_exceptions() + if isinstance(frame_type, list) and len(frame_type) == 1: + frame_type = frame_type[0] + if self._is_debugging: + LOGGER.debug('Waiting on %r frame(s)', frame_type) + start_state = self.state + self._waiting = True + while (start_state == self.state and not self.closed + and not self._connection.closed): + value = self._read_from_queue() + if value is not None: + self._check_for_rpc_request(value) + if frame_type and self._validate_frame_type(value, frame_type): + self._waiting = False + return value + self._read_queue.put(value) + + # Allow for any exceptions to be raised + self._check_for_exceptions() + + # If the wait interrupt is set, break out of the loop + if self._interrupt_is_set: + break + + self._waiting = False + + # Clear here to ensure out of processing loop before proceeding + if self._interrupt_is_set: + self._on_interrupt_set() diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index e69de29..58cc29b 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -0,0 +1,534 @@ +""" +Implement the base channel construct that is used by rabbitpy objects +such as :py:class:`Exchange ` or +:py:class:`Exchange `. It is responsible for coordinating +the communication between the IO thread and the higher-level objects. + +""" + +import logging +import queue +import socket +import types + +from pamqp import base as pamqp_base +from pamqp import commands, header + +from rabbitpy import ( + amqp, + amqp_queue, + base, + exceptions, + message, +) +from rabbitpy import ( + connection as conn, +) +from rabbitpy import ( + events as rabbitpy_events, +) + +LOGGER = logging.getLogger(__name__) + +BASIC_DELIVER = 'Basic.Deliver' +CONTENT_BODY = 'ContentBody' +CONTENT_HEADER = 'ContentHeader' + + +class Channel(base.AMQPChannel): + """The Channel object is the communications object used by Exchanges, + Messages, Queues, and Transactions. It is created by invoking the + :py:meth:`rabbitpy.Connection.channel() + ` method. It can act as a context + manager, allowing for quick shorthand use: + + .. code:: python + + with connection.channel(): + # Do something + + To create a new channel, invoke + py:meth:`~rabbitpy.connection.Connection.channel()` + + + To improve performance, pass blocking_read to True. Note that doing + so prevents ``KeyboardInterrupt``/CTRL-C from exiting the Python + interpreter. + + :param int channel_id: The channel # to use for this instance + :param dict server_capabilities: Features the server supports + :param events: Event management object + :param exception_queue: Exception queue + :param read_queue: The queue to read pending frames from + :param write_queue: The queue to write pending AMQP objs to + :param maximum_frame_size: The max frame size for msg bodies + :param write_trigger: Write to this socket to break IO waiting + :param blocking_read: Use blocking `Queue.get` to improve performance + :raises: rabbitpy.exceptions.RemoteClosedChannelException + :raises: rabbitpy.exceptions.AMQPException + + """ + + STATES = base.AMQPChannel.STATES + STATES[0x04] = 'Remotely Closed' + + def __init__( + self, + channel_id: int, + server_capabilities: dict, + events: rabbitpy_events.Events, + exception_queue: queue.Queue, + read_queue: queue.Queue, + write_queue: queue.Queue, + maximum_frame_size: int, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False, + ): + """Create a new instance of the Channel class""" + super().__init__( + exception_queue, write_trigger, connection, blocking_read + ) + self._channel_id = channel_id + self._consuming = False + self._events = events + self._maximum_frame_size = maximum_frame_size + self._publisher_confirms = False + self._read_queue = read_queue + self._write_queue = write_queue + self._server_capabilities = server_capabilities + self.consumers = {} + + def __enter__(self) -> 'Channel': + """For use as a context manager, return a handle to this object + instance. + + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ): + """When leaving the context, examine why the context is leaving, if + it's an exception or what. + + """ + if exc_type and exc_val: + LOGGER.debug('Exiting due to exception: %r', exc_val) + self._set_state(self.CLOSED) + raise exc_val + if self.open: + self.close() + + def cancel_consumer( + self, + obj: amqp.AMQP | amqp_queue.Queue, + consumer_tag: str | None = None, + nowait: bool = False, + ) -> None: + """Cancel the consuming of a queue. + + .. note:: This is a low level method and should not be directly used. + + :param obj: The queue to cancel + :param consumer_tag: The consumer tag to cancel + :param nowait: Do not wait on RPC to finish before returning + + """ + consumer_tag = consumer_tag or obj.consumer_tag + self._interrupt_wait_on_frame( + self._on_ready_to_cancel, consumer_tag, nowait + ) + + def close(self) -> None: + """Close the channel, cancelling any active consumers, purging the read + queue, while looking to see if a Basic.Nack should be sent, sending it + if so. + + """ + if self._connection.closed: + LOGGER.debug( + 'Channel %i close invoked when connection closed', + self._channel_id, + ) + elif self.closed: + LOGGER.debug( + 'Channel %i close invoked when already closed', + self._channel_id, + ) + else: + self._set_state(self.CLOSING) + + # Empty the queue and nack the max id (and all previous) + if self.consumers: + delivery_tag = 0 + discard_counter = 0 + ack_tags = [] + for queue_obj, no_ack in self.consumers.values(): + self.cancel_consumer(queue_obj) + if not no_ack: + LOGGER.debug( + 'Channel %i will nack messages for %s', + self._channel_id, + queue_obj.consumer_tag, + ) + ack_tags.append(queue_obj.consumer_tag) + + # If there are any ack tags, get the last msg to nack + if ack_tags: + while not self._read_queue.empty(): + frame_value = self._get_from_read_queue() + if not frame_value: + break + if ( + frame_value.name == BASIC_DELIVER + and frame_value.consumer_tag in ack_tags + ): + if delivery_tag < frame_value.delivery_tag: + delivery_tag = frame_value.delivery_tag + discard_counter += 1 + if delivery_tag: + self._multi_nack(delivery_tag) + + super().close() + + def consume_message(self) -> message.Message | None: + """Get a message from the stack, blocking while doing so. If a consumer + is cancelled out-of-band, we will receive a Basic.CancelOk + instead. + + .. note:: This is a low level method and should not be called directly. + + """ + if not self.consumers: + raise exceptions.NotConsumingError + frame = self._wait_on_frame([commands.Basic.Deliver]) + LOGGER.debug('Waited on frame, got %r', frame) + if frame: + return self._wait_for_content_frames(frame) + return None + + def enable_publisher_confirms(self) -> None: + """Turn on Publisher Confirms. If confirms are turned on, the + `Message.publish` command will return a bool indicating if a message + has been successfully published. + + """ + if not self._supports_publisher_confirms: + raise exceptions.NotSupportedError('Confirm.Select') + self.rpc(commands.Confirm.Select()) + self._publisher_confirms = True + + @property + def id(self) -> int: # pylint: disable=invalid-name + """Return the channel id""" + return self._channel_id + + @property + def maximum_frame_size(self) -> int: + """Return the AMQP maximum frame size""" + return self._maximum_frame_size + + def open(self) -> None: + """Open the channel, invoked upon creation by the Connection""" + self._set_state(self.OPENING) + self.write_frame(self._build_open_frame()) + self._wait_on_frame(commands.Channel.OpenOk) + self._set_state(self.OPEN) + LOGGER.debug('Channel #%i open', self._channel_id) + + def prefetch_count(self, value: int, all_channels: bool = False) -> None: + """Set a prefetch count for the channel (or all channels on the same + connection). + + :param value: The prefetch count to value + :param all_channels: Set the prefetch count on all channels on the + same connection + + """ + self.rpc( + commands.Basic.Qos(prefetch_count=value, global_=all_channels) + ) + + def prefetch_size(self, value: int, all_channels: bool = False) -> None: + """Set a prefetch size in bytes for the channel (or all channels on the + same connection). + + :param int value: The prefetch size value + :param bool all_channels: Set the prefetch size on all channels on the + same connection + + """ + if value: + self.rpc( + commands.Basic.Qos(prefetch_size=value, global_=all_channels) + ) + + @property + def publisher_confirms(self) -> bool: + """Returns True if publisher confirms are enabled.""" + return self._publisher_confirms + + def recover(self, requeue: bool = False) -> None: + """Recover all unacknowledged messages that are associated with this + channel. + + :param requeue: Requeue the message + + """ + self.rpc(commands.Basic.Recover(requeue=requeue)) + + def register_consumer( + self, + obj: amqp_queue.Queue, + no_ack: bool, + priority: int | None = None, + ) -> None: + """Register a Queue object as a consumer, issuing Basic.Consume. + + .. note:: This is a low level method and should not be called directly. + + :param obj: The queue to consume + :param no_ack: no_ack mode + :param priority: Consumer priority + :raises: ValueError + + """ + args = {} + if priority is not None: + if not self._supports_consumer_priorities: + raise exceptions.NotSupportedError('consumer_priorities') + if not isinstance(priority, int): + raise ValueError('Consumer priority must be an int') + args['x-priority'] = priority + self.rpc( + commands.Basic.Consume( + queue=obj.name, + consumer_tag=obj.consumer_tag, + no_ack=no_ack, + arguments=args, + ) + ) + self.consumers[obj.consumer_tag] = (obj, no_ack) + + @staticmethod + def _build_open_frame() -> commands.Channel.Open: + """Build and return a channel open frame""" + return commands.Channel.Open() + + def _on_ready_to_cancel(self, consumer_tag: str, nowait: bool) -> None: + self._check_for_exceptions() + if self.closed: + return + LOGGER.debug( + 'Cancelling consumer while %r (%r)', + self.state_description, + self._connection.state_description, + ) + if consumer_tag in self.consumers: + del self.consumers[consumer_tag] + if nowait: + self.write_frame( + commands.Basic.Cancel(consumer_tag=consumer_tag, nowait=True) + ) + return + self.rpc(commands.Basic.Cancel(consumer_tag=consumer_tag)) + + def _check_for_rpc_request(self, value: pamqp_base.Frame) -> None: + """Inspect a frame to see if it's an RPC request from RabbitMQ.""" + LOGGER.debug('Checking for RPC request: %r', value) + super()._check_for_rpc_request(value) + if isinstance(value, commands.Basic.Return): + raise exceptions.MessageReturnedException( + value.reply_code, + value.reply_text, + value.exchange, + value.routing_key, + ) + elif isinstance(value, commands.Basic.Cancel): + self._waiting = False + if value.consumer_tag in self.consumers: + del self.consumers[value.consumer_tag] + raise exceptions.RemoteCancellationException(value.consumer_tag) + + def _create_message( + self, + method_frame: pamqp_base.Frame | None, + header_frame: header.ContentHeader | None, + body: str | bytes | None, + ) -> message.Message | None: + """Create a message instance with the channel it was received on and + the dictionary of message parts. Will return None if no message can be + created. + + :param method_frame: The method frame value + :param header_frame: Header frame value + :param body: The message body + + """ + if not method_frame: + LOGGER.warning('Received empty method_frame, returning None') + return None + if not header_frame: + LOGGER.debug('Malformed header frame: %r', header_frame) + props = header_frame.properties.to_dict() if header_frame else {} + msg = message.Message(self, body, props) + msg.method = method_frame + msg.name = method_frame.name + return msg + + def _get_from_read_queue(self) -> pamqp_base.Frame | None: + """Fetch a frame from the read queue and return it, otherwise return + None + + """ + try: + frame_value = self._read_queue.get(False) + except queue.Empty: + return None + + try: + self._read_queue.task_done() + except ValueError: + pass + return frame_value + + def get_message(self) -> message.Message | None: + """Try and get a delivered message from the connection's stack. + + .. note: This is for internal use only + + """ + frame_value = self._wait_on_frame( + [commands.Basic.GetOk, commands.Basic.GetEmpty] + ) + if isinstance(frame_value, commands.Basic.GetEmpty): + return None + return self._wait_for_content_frames(frame_value) + + def _multi_nack(self, delivery_tag: int, requeue: bool = True) -> None: + """Send a multiple negative acknowledgement, re-queueing the items + + :param delivery_tag: The delivery tag for this channel + :param requeue: Requeue the messages + + """ + if not self._supports_basic_nack: + raise exceptions.NotSupportedError('Basic.Nack') + if self._is_debugging: + LOGGER.debug('Sending Basic.Nack with requeue') + self.rpc( + commands.Basic.Nack( + delivery_tag=delivery_tag, multiple=True, requeue=requeue + ) + ) + + def _reject_inbound_message( + self, method_frame: commands.Basic.Deliver + ) -> None: + """Used internally to reject a message when it's been received during + a state that it should not have been. + + :param method_frame: The method frame + + """ + self.rpc( + commands.Basic.Reject( + delivery_tag=method_frame.delivery_tag, requeue=True + ) + ) + + @property + def _supports_basic_nack(self) -> bool: + """Indicates if the server supports `Basic.Nack`""" + return self._server_capabilities.get('basic.nack', False) + + @property + def _supports_consumer_cancel_notify(self) -> bool: + """Indicates if the server supports sending consumer cancellation + notifications + + """ + return self._server_capabilities.get('consumer_cancel_notify', False) + + @property + def _supports_consumer_priorities(self) -> bool: + """Indicates if the server supports consumer priorities""" + return self._server_capabilities.get('consumer_priorities', False) + + @property + def _supports_per_consumer_qos(self) -> bool: + """Indicates if the server supports per consumer qos""" + return self._server_capabilities.get('per_consumer_qos', False) + + @property + def _supports_publisher_confirms(self) -> bool: + """Indicates if the server supports publisher confirmations""" + return self._server_capabilities.get('publisher_confirms', False) + + def _wait_for_content_frames( + self, + method_frame: commands.Basic.Deliver + | commands.Basic.Get + | commands.Basic.Return, + ) -> message.Message | None: + """Used by both Channel._get_message and Channel._consume_message for + getting a message parts off the queue and returning the fully + constructed message. + + :param method_frame: The method frame for the message + + """ + if self.closing or self.closed: + return None + + consuming = isinstance(method_frame, commands.Basic.Deliver) + if consuming and not self.consumers: + return None + + if self._is_debugging: + LOGGER.debug( + 'Waiting on content frames for %s: %r', + method_frame.name, + method_frame.delivery_tag, + ) + + header_value = self._wait_on_frame(CONTENT_HEADER) + if not header_value: + self._reject_inbound_message(method_frame) + return None + + self._check_for_rpc_request(header_value) + if self._interrupt_is_set: + return self._on_interrupt_set() + + error = False + + body_value = bytearray() + body_length_received = 0 + body_total_size = header_value.body_size + + while body_length_received < body_total_size: + body_part = self._wait_on_frame(CONTENT_BODY) + + self._check_for_rpc_request(body_part) + if self._interrupt_is_set: + self._on_interrupt_set() + error = True + elif not body_part: + break + elif self.closing or self.closed: + error = True + elif consuming and not self.consumers: + self._reject_inbound_message(method_frame) + error = True + if error: + return + + body_length_received += len(body_part.value) + body_value += body_part.value + + return self._create_message(method_frame, header_value, body_value) diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py new file mode 100644 index 0000000..2f186d6 --- /dev/null +++ b/rabbitpy/exchange.py @@ -0,0 +1,188 @@ +""" +The :py:class:`Exchange` class is used to create and manage exchanges in +RabbitMQ and provides four classes as wrappers: + +* :py:class:`DirectExchange` +* :py:class:`FanoutExchange` +* :py:class:`HeadersExchange` +* :py:class:`TopicExchange` + +""" +import logging +import typing + +from pamqp import commands + +from rabbitpy import base +from rabbitpy import channel as chan + +LOGGER = logging.getLogger(__name__) + +ExchangeTypes = typing.Union[str, '_Exchange', 'Exchange', 'DirectExchange', + 'FanoutExchange', 'HeadersExchange', + 'TopicExchange'] + + +class _Exchange(base.AMQPClass): + """Exchange class for interacting with an exchange in RabbitMQ including + declaration, binding and deletion. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + durable = False + arguments = {} + auto_delete = False + type = 'direct' + + def __init__(self, + channel: chan.Channel, + name: str, + durable: bool = False, + auto_delete: bool = False, + arguments: dict | None = None): + """Create a new instance of the exchange object.""" + super().__init__(channel, name) + self.durable = durable + self.auto_delete = auto_delete + self.arguments = arguments or {} + + def bind(self, + source: ExchangeTypes, + routing_key: str | None = None) -> None: + """Bind to another exchange with the routing key. + + :param source: The exchange to bind to + :param routing_key: The routing key to use + + """ + if hasattr(source, 'name'): + source = source.name + self._rpc( + commands.Exchange.Bind( + destination=self.name, source=source, routing_key=routing_key)) + + def declare(self, passive: bool = False) -> None: + """Declare the exchange with RabbitMQ. If passive is True and the + command arguments do not match, the channel will be closed. + + :param passive: Do not actually create the exchange + + """ + self._rpc( + commands.Exchange.Declare( + exchange=self.name, exchange_type=self.type, + durable=self.durable, passive=passive, + auto_delete=self.auto_delete, arguments=self.arguments)) + + def delete(self, if_unused: bool = False) -> None: + """Delete the exchange from RabbitMQ. + + :param bool if_unused: Delete only if unused + + """ + self._rpc( + commands.Exchange.Delete( + exchange=self.name, if_unused=if_unused)) + + def unbind(self, + source: ExchangeTypes, + routing_key: str | None = None) -> None: + """Unbind the exchange from the source exchange with the + routing key. If routing key is None, use the queue or exchange name. + + :param source: The exchange to unbind from + :param routing_key: The routing key that binds them + + """ + if hasattr(source, 'name'): + source = source.name + self._rpc( + commands.Exchange.Unbind( + destination=self.name, source=source, routing_key=routing_key)) + + +class Exchange(_Exchange): + """Exchange class for interacting with an exchange in RabbitMQ including + declaration, binding and deletion. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param exchange_type: The exchange type + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + + def __init__(self, + channel: chan.Channel, + name: str, + exchange_type: str = 'direct', + durable: bool = False, + auto_delete: bool = False, + arguments: bool | None = None): + """Create a new instance of the exchange object.""" + self.type = exchange_type + super().__init__( + channel, name, durable, auto_delete, arguments) + + +class DirectExchange(_Exchange): + """The DirectExchange class is used for interacting with direct exchanges + only. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + type = 'direct' + + +class FanoutExchange(_Exchange): + """The FanoutExchange class is used for interacting with fanout exchanges + only. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + type = 'fanout' + + +class HeadersExchange(_Exchange): + """The HeadersExchange class is used for interacting with direct exchanges + only. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + type = 'headers' + + +class TopicExchange(_Exchange): + """The TopicExchange class is used for interacting with topic exchanges + only. + + :param channel: The channel object to communicate on + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments + + """ + type = 'topic' diff --git a/rabbitpy/heartbeat.py b/rabbitpy/heartbeat.py new file mode 100644 index 0000000..0c0934c --- /dev/null +++ b/rabbitpy/heartbeat.py @@ -0,0 +1,68 @@ +""" +The heartbeat class implements the logic for sending heartbeats every +configured interval. + +""" +import logging +import threading + +from rabbitpy import channel0 as rabbitpy_channel0 +from rabbitpy import io as rabbitpy_io + +LOGGER = logging.getLogger(__name__) + + +class Heartbeat: + """Send a heartbeat frame every interval if no data has been written. + + :param io: Used to get the # of bytes written each interval + :param channel0: The channel that the heartbeat is sent over. + + """ + def __init__(self, + io: rabbitpy_io.IO, + channel0: rabbitpy_channel0.Channel0, + interval: float): + self._channel0 = channel0 + self._interval = float(interval) / 2.0 + self._io = io + self._last_written = self._io.bytes_written + self._lock = threading.Lock() + self._timer: threading.Timer | None = None + + def start(self) -> None: + """Start the heartbeat checker""" + if not self._interval: + LOGGER.debug('Heartbeats are disabled, not starting') + return + self._start_timer() + LOGGER.debug( + 'Heartbeat started, ensuring data is written every %.2f seconds', + self._interval) + + def stop(self) -> None: + """Stop the heartbeat checker""" + if self._timer: + self._timer.cancel() + self._timer = None + + def _start_timer(self) -> None: + """Create a new thread timer, destroying the last if it existed.""" + if self._timer: + del self._timer + self._timer = threading.Timer(self._interval, self._maybe_send) + self._timer.daemon = True + self._timer.start() + + def _maybe_send(self) -> None: + """Fired by threading.Timer every ``self._interval`` seconds to + maybe send a heartbeat to the remote connection, if no other frames + have been written. + + """ + if not self._io.bytes_written - self._last_written: + self._channel0.send_heartbeat() + self._lock.acquire(True) + self._last_written = self._io.bytes_written + self._lock.release() + self._start_timer() diff --git a/rabbitpy/message.py b/rabbitpy/message.py new file mode 100644 index 0000000..6cabd7d --- /dev/null +++ b/rabbitpy/message.py @@ -0,0 +1,378 @@ +""" +The Message class represents a message that is sent or received and contains +methods for publishing the message, or in the case that the message was +delivered by RabbitMQ, acknowledging it, rejecting it or negatively +acknowledging it. + +""" +import datetime +import json +import logging +import math +import time +import typing +import uuid + +from pamqp import body, commands, header + +from rabbitpy import base, exceptions, utils +from rabbitpy import channel as chan +from rabbitpy import exchange as exc + +LOGGER = logging.getLogger(__name__) + + +class Properties(commands.Basic.Properties): + """Proxy class for :py:class:`pamqp.specification.Basic.Properties`""" + pass + + +class Message(base.AMQPClass): + """Created by both rabbitpy internally when a message is delivered or + returned from RabbitMQ and by implementing applications, the Message class + is used to publish a message to and access and respond to a message from + RabbitMQ. + + When specifying properties for a message, pass in a dict of key value items + that match the AMQP Basic.Properties specification with a small caveat. + + Due to an overlap in the AMQP specification and the Python keyword + :code:`type`, the :code:`type` property is referred to as + :code:`message_type`. + + The following is a list of the available properties: + + * app_id + * content_type + * content_encoding + * correlation_id + * delivery_mode + * expiration + * headers + * message_id + * message_type + * priority + * reply_to + * timestamp + * user_id + + **Automated features** + + When passing in the body value, if it is a dict or list, it will + automatically be JSON serialized and the content type ``application/json`` + will be set on the message properties. + + When publishing a message to RabbitMQ, if the opinionated value is ``True`` + and no ``message_id`` value was passed in as a property, a UUID will be + generated and specified as a property of the message. + + Additionally, if opinionated is ``True`` and the ``timestamp`` property + is not specified when passing in ``properties``, the current Unix epoch + value will be set in the message properties. + + :param channel: The channel object for the message object to act upon + :param body_value: The message body + :param properties: A dictionary of message properties + :param opinionated: Automatically populate properties if True + :raises KeyError: Raised when an invalid property is passed in + + """ + method = None + name = 'Message' + + def __init__(self, + channel: chan.Channel, + body_value: str | bytes | memoryview | dict | list, + properties: dict | None = None, + opinionated: bool = False): + """Create a new instance of the Message object.""" + super().__init__(channel, 'Message') + + # Always have a dict of properties set + self.properties = properties or {} + + # Assign the body value + if isinstance(body_value, memoryview): + self.body = bytes(body_value) + else: + self.body = self._auto_serialize(body_value) + + # Add a message id if auto_id is not turned off and it is not set + if opinionated and 'message_id' not in self.properties: + self._add_auto_message_id() + + if opinionated: + if 'timestamp' not in self.properties: + self._add_timestamp() + + # Enforce datetime timestamps + if 'timestamp' in self.properties: + self.properties['timestamp'] = \ + self._as_datetime(self.properties['timestamp']) + + # Don't let invalid property keys in + if self._invalid_properties: + raise KeyError(f'Invalid property: {self._invalid_properties[0]}') + + @property + def delivery_tag(self) -> int | None: + """Return the delivery tag for a message that was delivered or gotten + from RabbitMQ. + + """ + return self.method.delivery_tag if self.method else None + + @property + def redelivered(self) -> bool | None: + """Indicates if this message may have been delivered before (but not + acknowledged) + + """ + return self.method.redelivered if self.method else None + + @property + def routing_key(self) -> str | None: + """Return the routing_key for a message that was delivered or gotten + from RabbitMQ. + + """ + return self.method.routing_key if self.method else None + + @property + def exchange(self) -> str | None: + """Return the source exchange for a message that was delivered or + gotten from RabbitMQ. + + """ + return self.method.exchange if self.method else None + + def ack(self, all_previous: bool = False) -> None: + """Acknowledge receipt of the message to RabbitMQ. Will raise an + ActionException if the message was not received from a broker. + + :raises: ActionException + + """ + if not self.method: + raise exceptions.ActionException( + 'Can not ack non-received message') + basic_ack = commands.Basic.Ack(self.method.delivery_tag, + multiple=all_previous) + self.channel.write_frame(basic_ack) + + def json(self) -> bytes: + """Deserialize the message body if it is JSON, returning the value.""" + try: + return json.loads(self.body) + except TypeError: # pragma: no cover + return json.loads(self.body.decode('utf-8')) + + def nack(self, requeue: bool = False, all_previous: bool = False) -> None: + """Negatively acknowledge receipt of the message to RabbitMQ. Will + raise an ActionException if the message was not received from a broker. + + :param requeue: Requeue the message + :param all_previous: Nack all previous unacked messages up to and + including this one + :raises: ActionException + + """ + if not self.method: + raise exceptions.ActionException( + 'Can not nack non-received message') + basic_nack = commands.Basic.Nack(self.method.delivery_tag, + requeue=requeue, + multiple=all_previous) + self.channel.write_frame(basic_nack) + + def pprint(self, properties: bool = False) -> None: # pragma: no cover + """Print a formatted representation of the message. + + :param properties: Include properties in the representation + + """ + if properties: + pass + + def publish(self, + exchange: exc.ExchangeTypes, + routing_key: str = '', + mandatory: bool = False, + immediate: bool = False) -> bool | None: + """Publish the message to the exchange with the specified routing + key. + + If the message is a ``str`` value it will be converted to + a ``bytes`` value using ``bytes(value.encode('UTF-8'))``. If you do + not want the auto-conversion to take place, set the body to a + ``bytes`` value prior to publishing. + + :param exchange: The exchange to publish the message to + :param routing_key: The routing key to use + :param mandatory: Requires the message is published + :param immediate: Request immediate delivery + :raises: rabbitpy.exceptions.MessageReturnedException + + """ + if isinstance(exchange, base.AMQPClass): + exchange = exchange.name + + # Coerce the body to the proper type + payload = utils.maybe_utf8_encode(self.body) + + frames = [ + commands.Basic.Publish(exchange=exchange, + routing_key=routing_key or '', + mandatory=mandatory, + immediate=immediate), + header.ContentHeader(body_size=len(payload), + properties=self._properties) + ] + + # Calculate how many body frames are needed + pieces = math.ceil(len(payload) / float(self.channel.maximum_frame_size)) + + # Send the message + for offset in range(0, pieces): + start = self.channel.maximum_frame_size * offset + end = start + self.channel.maximum_frame_size + if end > len(payload): + end = len(payload) + frames.append(body.ContentBody(payload[start:end])) + + # Write the frames out + self.channel.write_frames(frames) + + # If publisher confirmations are enabled, wait for the response + if self.channel.publisher_confirms: + response = self.channel.wait_for_confirmation() + if isinstance(response, commands.Basic.Ack): + return True + elif isinstance(response, commands.Basic.Nack): + return False + else: + raise exceptions.UnexpectedResponseError(response) + + def reject(self, requeue: bool = False) -> None: + """Reject receipt of the message to RabbitMQ. Will raise + an ActionException if the message was not received from a broker. + + :param requeue: Requeue the message + :raises: ActionException + + """ + if not self.method: + raise exceptions.ActionException( + 'Can not reject non-received message') + basic_reject = commands.Basic.Reject(self.method.delivery_tag, + requeue=requeue) + self.channel.write_frame(basic_reject) + + def _add_auto_message_id(self) -> None: + """Set the message_id property to a new UUID.""" + self.properties['message_id'] = str(uuid.uuid4()) + + def _add_timestamp(self) -> None: + """Add the timestamp to the properties""" + self.properties['timestamp'] = datetime.datetime.now(tz=datetime.UTC) + + @staticmethod + def _as_datetime(value: datetime.datetime | time.struct_time | int | float | str | bytes) \ + -> datetime.datetime | None: + """Return the passed in value as a ``datetime.datetime`` value. + + :param value: The value to convert or pass through + :raises: TypeError + + """ + if value is None: + return None + + if isinstance(value, datetime.datetime): + return value + + if isinstance(value, time.struct_time): + return datetime.datetime(*value[:6], tzinfo=datetime.UTC) + + if isinstance(value, (bytes, str)): + value = int(value) + + if isinstance(value, float) or isinstance(value, int): + return datetime.datetime.fromtimestamp(value, tz=datetime.UTC) + + raise TypeError( + f'Could not cast a {value} value to a datetime.datetime') + + def _auto_serialize(self, value: str | bytes | memoryview | dict | list) \ + -> str | bytes: + """Automatically serialize the body as JSON if it is a dict or list. + + :param value: The message body passed into the constructor + + """ + if isinstance(value, dict) or isinstance(value, list): + self.properties['content_type'] = 'application/json' + return json.dumps(value, ensure_ascii=False) + return value + + # Map pamqp 4 amqp_type strings to Python types for coercion + _AMQP_TYPE_MAP: typing.ClassVar[dict[str, str]] = { + 'shortstr': 'str', + 'octet': 'int', + 'table': 'dict', + 'timestamp': 'datetime', + } + + def _coerce_properties(self) -> None: + """Force properties to be set to the correct data type""" + for key, value in self.properties.items(): + if self.properties[key] is None: + continue + try: + amqp_type = commands.Basic.Properties.amqp_type(key) + except AttributeError: + continue + python_type = self._AMQP_TYPE_MAP.get(amqp_type) + if python_type == 'str': + if not isinstance(value, (bytes, str)): + LOGGER.warning('Coercing property %s to bytes', key) + value = str(value) + self.properties[key] = utils.maybe_utf8_encode(value) + elif python_type == 'int': + LOGGER.warning('Coercing property %s to int', key) + try: + self.properties[key] = int(value) + except TypeError as error: + LOGGER.warning('Could not coerce %s: %s', key, error) + elif python_type == 'dict' and not isinstance(value, dict): + LOGGER.warning('Resetting invalid value for %s to None', key) + self.properties[key] = {} + if python_type == 'datetime': + self.properties[key] = self._as_datetime(value) + + @property + def _invalid_properties(self) -> list[str]: + """Return a list of invalid properties that currently exist in the the + properties that are set. + + """ + return [ + key for key in self.properties + if key not in commands.Basic.Properties.attributes() + ] + + @property + def _properties(self) -> commands.Basic.Properties: + """Return a new Basic.Properties object representing the message + properties. + + """ + self._prune_invalid_properties() + self._coerce_properties() + return commands.Basic.Properties(**self.properties) + + def _prune_invalid_properties(self) -> None: + """Remove invalid properties from the message properties.""" + for key in self._invalid_properties: + LOGGER.warning('Removing invalid property "%s"', key) + del self.properties[key] diff --git a/rabbitpy/queue.py b/rabbitpy/queue.py deleted file mode 100644 index e69de29..0000000 diff --git a/rabbitpy/simple.py b/rabbitpy/simple.py new file mode 100644 index 0000000..c55aaa6 --- /dev/null +++ b/rabbitpy/simple.py @@ -0,0 +1,297 @@ +""" +Wrapper methods for easy access to common operations, making them both less +complex and less verbose for one-off or simple use cases. + +""" +import types +import typing + +from rabbitpy import amqp_queue, channel, connection, exchange, message + + +class SimpleChannel: + """The rabbitpy.simple.Channel class creates a context manager + implementation for use on a single channel where the connection is + automatically created and managed for you. + + Example: + + .. code:: python + + import rabbitpy + + with rabbitpy.SimpleChannel('amqp://localhost/%2f') as channel: + queue = rabbitpy.Queue(channel, 'my-queue') + + :param str uri: The AMQP URI to connect with. For URI options, see the + :class:`~rabbitpy.connection.Connection` class documentation. + + """ + + def __init__(self, uri: str): + self.connection = None + self.channel = None + self.uri = uri + + def __enter__(self) -> channel.Channel: + self.connection = connection.Connection(self.uri) + self.channel = self.connection.channel() + return self.channel + + def __exit__(self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None): + if not self.channel.closed: + self.channel.close() + if not self.connection.closed: + self.connection.close() + if exc_type and exc_val: + raise + + +def consume(uri: str | None = None, + queue_name: str | None = None, + no_ack: bool | None = False, + prefetch: int | None = None, + priority: int | None = None) \ + -> typing.Generator[message.Message, None, None]: + """Consume messages from the queue as a generator: + + .. code:: python + + for message in rabbitpy.consume('amqp://localhost/%2F', 'my-queue'): + message.ack() + + :param uri: AMQP connection URI + :param queue_name: The name of the queue to consume from + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Set the consumer priority + :raises: py:class:`ValueError` + + """ + _validate_name(queue_name, 'queue') + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) + yield from queue.consume(no_ack, prefetch, priority) + + +def get(uri: str | None = None, + queue_name: str | None = None) \ + -> message.Message | None: + """Get a message from RabbitMQ, auto-acknowledging with RabbitMQ if one + is returned. + + Invoke directly as ``rabbitpy.get()`` + + :param uri: AMQP URI to connect to + :param queue_name: The queue name to get the message from + :raises: py:class:`ValueError` + + """ + _validate_name(queue_name, 'queue') + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) + return queue.get(False) + + +def publish(uri: str | None = None, + exchange_name: str | None = None, + routing_key: str | None = None, + body: bytes | str | None = None, + properties: dict | None = None, + confirm: bool = False) -> bool | None: + """Publish a message to RabbitMQ. This should only be used for one-off + publishing, as you will suffer a performance penalty if you use it + repeatedly instead creating a connection and channel and publishing on that + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange to publish to + :param routing_key: The routing_key to publish with + :param body: The message body + :param properties: Dict representation of Basic.Properties + :param confirm: Confirm this delivery with Publisher Confirms + + """ + if exchange_name is None: + exchange_name = '' + + with SimpleChannel(uri) as chan: + msg = message.Message(chan, body or '', properties or {}) + if confirm: + chan.enable_publisher_confirms() + return msg.publish( + exchange_name, routing_key or '', mandatory=True) + else: + msg.publish(exchange_name, routing_key or '') + + +def create_queue(uri: str | None = None, + queue_name: str = '', + durable: bool = True, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None): + """Create a queue with RabbitMQ. This should only be used for one-off + operations. If a queue name is omitted, the name will be automatically + generated by RabbitMQ. + + :param uri: AMQP URI to connect to + :param queue_name: The queue name to create + :param durable: Indicates if the queue should survive a RabbitMQ is restart + :param auto_delete: Automatically delete when all consumers disconnect + :param max_length: Maximum queue length + :param message_ttl: Time-to-live of a message in milliseconds + :param expires: Milliseconds until a queue is removed after becoming idle + :param dead_letter_exchange: Dead letter exchange for rejected messages + :param dead_letter_routing_key: Routing key for dead lettered messages + :param arguments: Custom arguments for the queue + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _validate_name(queue_name, 'queue') + with SimpleChannel(uri) as c: + amqp_queue.Queue(c, + queue_name, + durable=durable, + auto_delete=auto_delete, + max_length=max_length, + message_ttl=message_ttl, + expires=expires, + dead_letter_exchange=dead_letter_exchange, + dead_letter_routing_key=dead_letter_routing_key, + arguments=arguments).declare() + + +def delete_queue(uri: str | None = None, + queue_name: str = '') -> None: + """Delete a queue from RabbitMQ. This should only be used for one-off + operations. + + :param uri: AMQP URI to connect to + :param queue_name: The queue name to delete + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _validate_name(queue_name, 'queue') + with SimpleChannel(uri) as c: + amqp_queue.Queue(c, queue_name).delete() + + +def create_direct_exchange(uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True) -> None: + """Create a direct exchange with RabbitMQ. This should only be used for + one-off operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _create_exchange(uri, exchange_name, exchange.DirectExchange, durable) + + +def create_fanout_exchange(uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True) -> None: + """Create a fanout exchange with RabbitMQ. This should only be used for + one-off operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _create_exchange(uri, exchange_name, exchange.FanoutExchange, durable) + + +def create_headers_exchange(uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True) -> None: + """Create a headers exchange with RabbitMQ. This should only be used for + one-off operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _create_exchange(uri, exchange_name, exchange.HeadersExchange, durable) + + +def create_topic_exchange(uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True) -> None: + """Create an exchange from RabbitMQ. This should only be used for one-off + operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _create_exchange(uri, exchange_name, exchange.TopicExchange, durable) + + +def delete_exchange(uri: str | None = None, + exchange_name: str | None = None) -> None: + """Delete an exchange from RabbitMQ. This should only be used for one-off + operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to delete + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _validate_name(exchange_name, 'exchange') + with SimpleChannel(uri) as c: + exchange.Exchange(c, exchange_name).delete() + + +def _create_exchange(uri: str | None, + exchange_name: str | None, + exchange_class: typing.Callable, + durable: bool) -> None: + """Create an exchange from RabbitMQ. This should only be used for one-off + operations. + + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param exchange_class: The exchange class to use + :param durable: Exchange should survive server restarts + :raises: :py:class:`ValueError` + :raises: :py:class:`rabbitpy.RemoteClosedException` + + """ + _validate_name(exchange_name, 'exchange') + with SimpleChannel(uri) as c: + exchange_class(c, exchange_name, durable=durable).declare() + + +def _validate_name(value: str, obj_type: str) -> None: + """Validate the specified name is set. + + :param value: The value to validate + :param obj_type: The object type for the error message if needed + :raises: ValueError + + """ + if not value: + raise ValueError(f'You must specify the {obj_type} name') diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py new file mode 100644 index 0000000..274d864 --- /dev/null +++ b/rabbitpy/tx.py @@ -0,0 +1,112 @@ +""" +The TX or transaction class implements transactional functionality in RabbitMQ +and allows for any AMQP command to be issued, then committed or rolled back. + +""" +import logging +import types + +from pamqp import commands + +from rabbitpy import base, exceptions +from rabbitpy import channel as chan + +LOGGER = logging.getLogger(__name__) + + +class Tx(base.AMQPClass): + """Work with transactions + + The Tx class allows publish and ack operations to be batched into atomic + units of work. The intention is that all publish and ack requests issued + within a transaction will complete successfully or none of them will. + Servers SHOULD implement atomic transactions at least where all publish or + ack requests affect a single queue. Transactions that cover multiple + queues may be non-atomic, given that queues can be created and destroyed + asynchronously, and such events do not form part of any transaction. + Further, the behaviour of transactions with respect to the immediate and + mandatory flags on Basic.Publish methods is not defined. + + :param channel: The channel object to start the transaction on + + """ + + def __init__(self, channel: chan.Channel): + super().__init__(channel, 'Tx') + self._selected = False + + def __enter__(self) -> 'Tx': + """For use as a context manager, return a handle to this object + instance. + + """ + self.select() + return self + + def __exit__(self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None): + """When leaving the context, examine why the context is leaving, if + it's an exception or what. + + """ + if exc_type: + LOGGER.warning('Exiting Transaction on exception: %r', exc_val) + if self._selected: + self.rollback() + raise exc_val + else: + LOGGER.debug('Committing transaction on exit of context block') + if self._selected: + self.commit() + + def select(self) -> bool: + """Select standard transaction mode + + This method sets the channel to use standard transactions. The client + must use this method at least once on a channel before using the Commit + or Rollback methods. + + """ + response = self._rpc(commands.Tx.Select()) + self._selected = isinstance(response, commands.Tx.SelectOk) + return self._selected + + def commit(self) -> bool: + """Commit the current transaction + + This method commits all message publications and acknowledgments + performed in the current transaction. A new transaction starts + immediately after a commit. + + :raises: rabbitpy.exceptions.NoActiveTransactionError + + """ + try: + response = self._rpc(commands.Tx.Commit()) + except exceptions.ChannelClosedException as error: + LOGGER.warning('Error committing transaction: %s', error) + raise exceptions.NoActiveTransactionError() from error + self._selected = False + return isinstance(response, commands.Tx.CommitOk) + + def rollback(self) -> bool: + """Abandon the current transaction + + This method abandons all message publications and acknowledgments + performed in the current transaction. A new transaction starts + immediately after a rollback. Note that unacked messages will not be + automatically redelivered by rollback; if that is required an explicit + recover call should be issued. + + :raises: rabbitpy.exceptions.NoActiveTransactionError + + """ + try: + response = self._rpc(commands.Tx.Rollback()) + except exceptions.ChannelClosedException as error: + LOGGER.warning('Error rolling back transaction: %s', error) + raise exceptions.NoActiveTransactionError() from error + self._selected = False + return isinstance(response, commands.Tx.RollbackOk) diff --git a/rabbitpy/utils.py b/rabbitpy/utils.py new file mode 100644 index 0000000..259d71d --- /dev/null +++ b/rabbitpy/utils.py @@ -0,0 +1,98 @@ +""" +Utilities +========= + +""" +import collections +import logging +import platform +import socket +from urllib import parse + +LOGGER = logging.getLogger('rabbitpy') +PYPY = platform.python_implementation() == 'PyPy' +PYTHON3 = True # Always True — rabbitpy 3.x requires Python 3 + +Parsed = collections.namedtuple( + 'Parsed', + 'scheme,netloc,path,params,query,fragment,username,password,hostname,port') + + +def maybe_utf8_encode(value: str | bytes) -> bytes: + """Cross-python version method that will attempt to utf-8 encode a string. + + :param value: The value to maybe encode + + """ + try: + return value.encode('utf-8') + except AttributeError: + return value + + +def urlparse(url: str) -> Parsed: + """Parse a URL, returning a named tuple result. + + :param url: The URL to parse + + """ + value = f'http{url[4:]}' if url[:4] == 'amqp' else url + parsed = parse.urlparse(value) + return Parsed(parsed.scheme.replace('http', 'amqp'), parsed.netloc, + parsed.path, parsed.params, parsed.query, parsed.fragment, + parsed.username, parsed.password, parsed.hostname, + parsed.port) + + +def is_string(value: object) -> bool: + """Return True if the value is a string or bytes value. + + :param value: The value to check + + """ + return isinstance(value, (str, bytes)) + + +def parse_qs(query_string: str) -> dict[str, list[str]]: + """Wrapper for :func:`urllib.parse.parse_qs`. + + :param query_string: The query string to parse + + """ + return parse.parse_qs(query_string) + + +def unquote(value: str) -> str: + """Wrapper for :func:`urllib.parse.unquote`. + + :param value: The value to unquote + + """ + return parse.unquote(value) + + +def trigger_write(sock: socket.socket) -> None: + try: + sock.send(b'0') + except OSError: + pass + + +class DebuggingOptimizationMixin: + """Micro-optimization to avoid logging overhead""" + + def __init__(self): + self._debugging: bool | None = None + + @property + def _is_debugging(self) -> bool: + """Indicates that something has set the logger to ``logging.DEBUG`` + to perform a minor micro-optimization preventing ``LOGGER.debug`` calls + when they are not required. + + :return: bool + + """ + if self._debugging is None: + self._debugging = LOGGER.getEffectiveLevel() == logging.DEBUG + return self._debugging diff --git a/tests/base_tests.py b/tests/base_tests.py new file mode 100644 index 0000000..27862e0 --- /dev/null +++ b/tests/base_tests.py @@ -0,0 +1,36 @@ +""" +Test the rabbitpy.base classes + +""" +from rabbitpy import base, utils +from tests import helpers + + +class AMQPClassTests(helpers.TestCase): + + def test_channel_valid(self): + obj = base.AMQPClass(self.channel, 'Foo') + self.assertEqual(obj.channel, self.channel) + + def test_channel_invalid(self): + self.assertRaises(ValueError, base.AMQPClass, 'Foo', 'Bar') + + def test_name_bytes(self): + obj = base.AMQPClass(self.channel, b'Foo') + self.assertIsInstance(obj.name, bytes) + + def test_name_str(self): + obj = base.AMQPClass(self.channel, 'Foo') + self.assertIsInstance(obj.name, str) + + @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') + def test_name_py2_unicode(self): + obj = base.AMQPClass(self.channel, 'Foo') + self.assertIsInstance(obj.name, str) + + def test_name_value(self): + obj = base.AMQPClass(self.channel, 'Foo') + self.assertEqual(obj.name, 'Foo') + + def test_name_invalid(self): + self.assertRaises(ValueError, base.AMQPClass, self.channel, 1) diff --git a/tests/channel_test.py b/tests/channel_test.py new file mode 100644 index 0000000..6372eee --- /dev/null +++ b/tests/channel_test.py @@ -0,0 +1,64 @@ +""" +Test the rabbitpy.channel classes + +""" +from rabbitpy import exceptions +from tests import helpers + + +class ServerCapabilitiesTest(helpers.TestCase): + + def test_basic_nack_disabled(self): + self.channel._server_capabilities['basic.nack'] = False + self.assertFalse(self.channel._supports_basic_nack) + + def test_basic_nack_enabled(self): + self.channel._server_capabilities['basic.nack'] = True + self.assertTrue(self.channel._supports_basic_nack) + + def test_consumer_cancel_notify_disabled(self): + self.channel._server_capabilities['consumer_cancel_notify'] = False + self.assertFalse(self.channel._supports_consumer_cancel_notify) + + def test_consumer_cancel_notify_enabled(self): + self.channel._server_capabilities['consumer_cancel_notify'] = True + self.assertTrue(self.channel._supports_consumer_cancel_notify) + + def test_consumer_priorities_disabled(self): + self.channel._server_capabilities['consumer_priorities'] = False + self.assertFalse(self.channel._supports_consumer_priorities) + + def test_consumer_priorities_enabled(self): + self.channel._server_capabilities['consumer_priorities'] = True + self.assertTrue(self.channel._supports_consumer_priorities) + + def test_per_consumer_qos_disabled(self): + self.channel._server_capabilities['per_consumer_qos'] = False + self.assertFalse(self.channel._supports_per_consumer_qos) + + def test_per_consumer_qos_enabled(self): + self.channel._server_capabilities['per_consumer_qos'] = True + self.assertTrue(self.channel._supports_per_consumer_qos) + + def test_publisher_confirms_disabled(self): + self.channel._server_capabilities['publisher_confirms'] = False + self.assertFalse(self.channel._supports_publisher_confirms) + + def test_publisher_confirms_enabled(self): + self.channel._server_capabilities['publisher_confirms'] = True + self.assertTrue(self.channel._supports_publisher_confirms) + + def test_invoking_consume_raises(self): + self.channel._server_capabilities['consumer_priorities'] = False + self.assertRaises(exceptions.NotSupportedError, + self.channel._consume, self, True, 100) + + def test_invoking_basic_nack_raises(self): + self.channel._server_capabilities['basic_nack'] = False + self.assertRaises(exceptions.NotSupportedError, + self.channel._multi_nack, 100) + + def test_invoking_enable_publisher_confirms_raises(self): + self.channel._server_capabilities['publisher_confirms'] = False + self.assertRaises(exceptions.NotSupportedError, + self.channel.enable_publisher_confirms) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..f152dad --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,28 @@ +import queue +import unittest +from unittest import mock + +from rabbitpy import channel, connection, events + + +class TestCase(unittest.TestCase): + + def setUp(self): + self.connection = mock.MagicMock('rabbitpy.connection.Connection') + self.connection._io = mock.Mock() + self.connection._io.write_trigger = mock.Mock('socket.socket') + self.connection._io.write_trigger.send = mock.Mock() + self.connection._channel0 = mock.Mock() + self.connection._channel0.properties = {} + self.connection._events = events.Events() + self.connection._exceptions = queue.Queue() + self.connection.open = True + self.connection.closed = False + self.channel = channel.Channel(1, {}, + self.connection._events, + self.connection._exceptions, + queue.Queue(), + queue.Queue(), 32768, + self.connection._io.write_trigger, + connection=self.connection) + self.channel._set_state(self.channel.OPEN) diff --git a/tests/integration_tests.py b/tests/integration_tests.py new file mode 100644 index 0000000..618d41c --- /dev/null +++ b/tests/integration_tests.py @@ -0,0 +1,522 @@ +import logging +import os +import re +import threading +import time + +import unittest +from urllib import parse +import uuid + +import rabbitpy +from rabbitpy import exceptions, utils + +LOGGER = logging.getLogger(__name__) + +logging.basicConfig(level=logging.DEBUG) +logging.getLogger('rabbitpy').setLevel(logging.DEBUG) + + +class ConfirmedPublishQueueLengthTest(unittest.TestCase): + + ITERATIONS = 5 + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.channel.enable_publisher_confirms() + self.exchange = rabbitpy.TopicExchange(self.channel, 'pql-test') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pql-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + for iteration in range(0, self.ITERATIONS): + message = rabbitpy.Message(self.channel, str(uuid.uuid4())) + if not message.publish(self.exchange, 'test.publish.pql'): + LOGGER.error('Error publishing message %i', iteration) + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_get_returns_expected_message(self): + self.assertEqual(len(self.queue), self.ITERATIONS) + + +class PublishAndGetTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.exchange = rabbitpy.TopicExchange(self.channel, 'test-pagt') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pagt-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + self.app_id = 'PublishAndGetTest' + self.message_body = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + self.message_type = 'test' + + self.msg = rabbitpy.Message(self.channel, + self.message_body, + {'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type}) + self.msg.publish(self.exchange, 'test.publish.get') + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_get_returns_expected_message(self): + msg = self.queue.get(True) + self.assertEqual(msg.body, self.message_body) + self.assertEqual(msg.properties['app_id'], self.app_id) + self.assertEqual(msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8')) + self.assertEqual(msg.properties['timestamp'], + self.msg.properties['timestamp']) + self.assertEqual(msg.properties['message_type'], self.message_type) + + +class PublishAndConsumeTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.exchange = rabbitpy.TopicExchange(self.channel, 'test-pact') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pact-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + self.app_id = 'PublishAndConsumeIteratorTest' + self.message_body = b'ABC1234567890' + self.message_type = 'test' + + self.msg = rabbitpy.Message(self.channel, + self.message_body, + {'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type}) + self.msg.publish(self.exchange, 'test.publish.consume') + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_get_returns_expected_message(self): + for msg in self.queue.consume(no_ack=True, prefetch=1): + self.assertEqual(msg.body, self.message_body) + self.assertEqual(msg.properties['app_id'], self.app_id) + self.assertEqual(msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8')) + self.assertEqual(msg.properties['timestamp'], + self.msg.properties['timestamp']) + self.assertEqual(msg.properties['message_type'], self.message_type) + break + if utils.PYPY: + self.queue.stop_consuming() + + +class PublishAndConsumeIteratorTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.exchange = rabbitpy.TopicExchange(self.channel, 'test-pacit') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pacit-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + self.app_id = 'PublishAndConsumeIteratorTest' + self.message_body = b'ABC1234567890' + self.message_type = 'test' + + self.msg = rabbitpy.Message(self.channel, + self.message_body, + {'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type}) + self.msg.publish(self.exchange, 'test.publish.consume') + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_iterator_returns_expected_message(self): + for msg in self.queue: + self.assertEqual(msg.body, self.message_body) + self.assertEqual(msg.properties['app_id'], self.app_id) + self.assertEqual(msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8')) + self.assertEqual(msg.properties['timestamp'], + self.msg.properties['timestamp']) + self.assertEqual(msg.properties['message_type'], self.message_type) + msg.ack() + LOGGER.info('breaking out of iterator') + break + if utils.PYPY: + self.queue.stop_consuming() + self.assertFalse(self.queue.consuming) + + +class PublishAndConsumeIteratorStopTest(unittest.TestCase): + + PUBLISH_COUNT = 10 + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.exchange = rabbitpy.TopicExchange(self.channel, 'test-pacist') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pacist-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + self.app_id = 'PublishAndConsumeIteratorTest' + self.message_body = 'ABC1234567890' + self.message_type = 'test' + + for iteration in range(0, self.PUBLISH_COUNT): + self.msg = rabbitpy.Message(self.channel, + self.message_body, + {'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type}) + self.msg.publish(self.exchange, + f'test.publish.consume {iteration}') + + def stop_consumer(self): + LOGGER.info('Stopping the consumer') + self.queue.stop_consuming() + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_iterator_exits_on_stop(self): + LOGGER.info('Starting stop timer') + timer = threading.Timer(2.5, self.stop_consumer) + timer.daemon = True + timer.start() + qty = 0 + for msg in self.queue: + if not msg: + LOGGER.info('Message is empty') + break + qty += 1 + msg.ack() + if utils.PYPY: + self.queue.stop_consuming() + LOGGER.info('Exited iterator, %r, %r', self.queue.consuming, qty) + self.assertFalse(self.queue.consuming) + self.assertEqual(qty, self.PUBLISH_COUNT) + + +class ConsumerTagTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.exchange = rabbitpy.TopicExchange(self.channel, 'test-pacit') + self.exchange.declare() + self.queue = rabbitpy.Queue(self.channel, 'pacit-queue') + self.queue.declare() + self.queue.bind(self.exchange, 'test.#') + + self.app_id = 'PublishAndConsumeIteratorTest' + self.message_body = b'ABC1234567890' + self.message_type = 'test' + + self.msg = rabbitpy.Message(self.channel, + self.message_body, + {'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type}) + self.msg.publish(self.exchange, 'test.publish.consume') + + def tearDown(self): + self.queue.delete() + self.exchange.delete() + self.channel.close() + self.connection.close() + + def test_iterator_returns_expected_message(self): + for msg in self.queue.consume(consumer_tag='test-tag'): + self.assertEqual(msg.body, self.message_body) + self.assertEqual(msg.properties['app_id'], self.app_id) + self.assertEqual(msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8')) + self.assertEqual(msg.properties['timestamp'], + self.msg.properties['timestamp']) + self.assertEqual(msg.properties['message_type'], self.message_type) + msg.ack() + LOGGER.info('breaking out of iterator') + break + if utils.PYPY: + self.queue.stop_consuming() + self.assertFalse(self.queue.consuming) + + + +class RedeliveredFlagTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + self.queue = rabbitpy.Queue(self.channel, 'redeliver-test') + self.queue.declare() + + # Publish the message that will be rejected + message = rabbitpy.Message(self.channel, 'Payload Value') + message.publish('', 'redeliver-test') + + # Get and reject the message + msg1 = self.queue.get() + msg1.reject(requeue=True) + + def tearDown(self): + self.queue.delete() + self.channel.close() + self.connection.close() + + def test_redelivered_flag_is_set(self): + msg = self.queue.get() + msg.ack() + self.assertTrue(msg.redelivered) + + +class UnnamedQueueDeclareTest(unittest.TestCase): + + def setUp(self): + self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + self.channel = self.connection.channel() + + def tearDown(self): + self.channel.close() + self.connection.close() + + def test_declaring_nameless_queue(self): + self.queue = rabbitpy.Queue(self.channel) + self.queue.declare() + matches = re.match(r'^amq\.gen-[\w_\-]+$', self.queue.name) + self.assertIsNotNone(matches) + self.queue.delete() + + +class SimpleCreateQueueTests(unittest.TestCase): + + def test_create_queue(self): + name = 'simple-create-queue' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, name) + response = queue.declare(True) + self.assertEqual(response, (0, 0)) + queue.delete() + + +class SimpleCreateDirectExchangeTests(unittest.TestCase): + + def test_create(self): + name = 'direct-exchange-name' + rabbitpy.create_direct_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.DirectExchange(channel, name) + obj.declare(True) + obj.delete() + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.create_direct_exchange) + + +class SimpleCreateFanoutExchangeTests(unittest.TestCase): + + def test_create(self): + name = 'fanout-exchange-name' + rabbitpy.create_fanout_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.FanoutExchange(channel, name) + obj.declare(True) + obj.delete() + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.create_fanout_exchange) + + +class SimpleCreateHeadersExchangeTests(unittest.TestCase): + + def test_create(self): + name = 'headers-exchange-name' + rabbitpy.create_headers_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.HeadersExchange(channel, name) + obj.declare(True) + obj.delete() + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.create_headers_exchange) + + +class SimpleCreateTopicExchangeTests(unittest.TestCase): + + def test_create(self): + name = 'topic-exchange-name' + rabbitpy.create_topic_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.TopicExchange(channel, name) + obj.declare(True) + obj.delete() + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.create_topic_exchange) + + +class SimpleDeleteExchangeTests(unittest.TestCase): + + def test_delete(self): + name = 'delete-exchange-name' + rabbitpy.create_topic_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + rabbitpy.delete_exchange( + os.environ['RABBITMQ_URL'], exchange_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.TopicExchange(channel, name) + self.assertRaises(exceptions.AMQPNotFound, + obj.declare, True) + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.delete_exchange) + + +class SimpleDeleteQueueTests(unittest.TestCase): + + def test_delete(self): + name = 'delete-queue-name' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) + with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: + with conn.channel() as channel: + obj = rabbitpy.Queue(channel, name) + self.assertRaises(exceptions.AMQPNotFound, + obj.declare, True) + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.delete_queue) + + +class SimpleGetTests(unittest.TestCase): + + def test_get_empty(self): + name = 'queue-name-get' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + self.assertIsNone( + rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name)) + rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) + + def test_get_msg(self): + body = b'test-body' + name = 'queue-name-get' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, body=body) + result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) + self.assertEqual(result.body, body) + rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) + + def test_raises_on_empty_name(self): + self.assertRaises(ValueError, rabbitpy.get) + + +class SimplePublishTests(unittest.TestCase): + + def test_publish_with_confirm(self): + body = b'test-body' + name = 'simple-publish' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + self.assertTrue( + rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, + body=body, confirm=True)) + result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) + self.assertEqual(result.body, body) + rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) + + +class SimpleConsumeTests(unittest.TestCase): + + def test_publish_with_confirm(self): + body = b'test-body' + name = 'simple-consume-tests' + rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) + self.assertTrue( + rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, + body=body, confirm=True)) + for message in rabbitpy.consume( + os.environ['RABBITMQ_URL'], queue_name=name, no_ack=True): + self.assertEqual(message.body, body) + break + rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) + + def test_raises_on_empty_name(self): + try: + for _msg in rabbitpy.consume(os.environ['RABBITMQ_URL']): + break + raise AssertionError('Did not raise ValueError') + except ValueError: + assert True + + +class Issue119TestCase(unittest.TestCase): + + def test_exception_is_raised(self): + conn = rabbitpy.Connection(os.environ['RABBITMQ_URL']) + channel = conn.channel() + channel.enable_publisher_confirms() + msg = rabbitpy.Message(channel, body_value='hello') + with self.assertRaises(exceptions.AMQPNotFound): + msg.publish(exchange='invalid', mandatory=True) + + + +class AccessDeniedDuringConnectionTests(unittest.TestCase): + + def test_exception_is_raised(self): + url_parts = parse.urlsplit(os.environ['RABBITMQ_URL']) + netloc = f'{uuid.uuid4().hex}:{uuid.uuid4().hex}@{url_parts.hostname}:{url_parts.port or 5672}' + url = parse.urlunsplit(( + url_parts.scheme, + netloc, + url_parts.path, + url_parts.query, + url_parts.fragment, + )) + with self.assertRaises(exceptions.AMQPAccessRefused): + rabbitpy.Connection(url) diff --git a/tests/test_amqp.py b/tests/test_amqp.py new file mode 100644 index 0000000..3119092 --- /dev/null +++ b/tests/test_amqp.py @@ -0,0 +1,20 @@ +""" +Test the rabbitpy.amqp class + +""" +from unittest import mock + +from rabbitpy import amqp +from tests import helpers + + +class BasicAckTests(helpers.TestCase): + + def test_basic_ack_invokes_write_frame(self): + with mock.patch.object(self.channel, 'write_frame') as method: + obj = amqp.AMQP(self.channel) + obj.basic_ack(123, True) + args, _kwargs = method.call_args + self.assertEqual(len(args), 1) + self.assertEqual(args[0].delivery_tag, 123) + self.assertEqual(args[0].multiple, True) diff --git a/tests/test_exchange.py b/tests/test_exchange.py new file mode 100644 index 0000000..7739539 --- /dev/null +++ b/tests/test_exchange.py @@ -0,0 +1,93 @@ +""" +Test the rabbitpy.exchange classes + +""" +from unittest import mock + +from pamqp import commands as specification + +from rabbitpy import exchange +from tests import helpers + + +class TxTests(helpers.TestCase): + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_declare(self, rpc): + rpc.return_value = specification.Exchange.DeclareOk + obj = exchange.Exchange(self.channel, 'foo') + obj.declare() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Declare) + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_delete(self, rpc): + rpc.return_value = specification.Exchange.DeleteOk + obj = exchange.Exchange(self.channel, 'foo') + obj.delete() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Delete) + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_bind(self, rpc): + rpc.return_value = specification.Exchange.BindOk + obj = exchange.Exchange(self.channel, 'foo') + obj.bind('a', 'b') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Bind) + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_unbind(self, rpc): + rpc.return_value = specification.Exchange.UnbindOk + obj = exchange.Exchange(self.channel, 'foo') + obj.unbind('a', 'b') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Unbind) + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_bind_obj(self, rpc): + rpc.return_value = specification.Exchange.BindOk + obj = exchange.Exchange(self.channel, 'foo') + val = mock.Mock() + val.name = 'bar' + obj.bind(val, 'b') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Bind) + + @mock.patch('rabbitpy.exchange.Exchange._rpc') + def test_bind_sends_exchange_unbind_obj(self, rpc): + rpc.return_value = specification.Exchange.UnbindOk + obj = exchange.Exchange(self.channel, 'foo') + val = mock.Mock() + val.name = 'bar' + obj.unbind(val, 'b') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Exchange.Unbind) + + +class DirectExchangeCreationTests(helpers.TestCase): + + def test_init_creates_direct_exchange(self): + obj = exchange.DirectExchange(self.channel, 'direct-test') + self.assertEqual(obj.type, 'direct') + + +class FanoutExchangeCreationTests(helpers.TestCase): + + def test_init_creates_direct_exchange(self): + obj = exchange.FanoutExchange(self.channel, 'fanout-test') + self.assertEqual(obj.type, 'fanout') + + +class HeadersExchangeCreationTests(helpers.TestCase): + + def test_init_creates_direct_exchange(self): + obj = exchange.HeadersExchange(self.channel, 'headers-test') + self.assertEqual(obj.type, 'headers') + + +class TopicExchangeCreationTests(helpers.TestCase): + + def test_init_creates_direct_exchange(self): + obj = exchange.TopicExchange(self.channel, 'topic-test') + self.assertEqual(obj.type, 'topic') diff --git a/tests/test_message.py b/tests/test_message.py new file mode 100644 index 0000000..726ec4b --- /dev/null +++ b/tests/test_message.py @@ -0,0 +1,500 @@ +""" +Test the rabbitpy.message.Message class + +""" +import datetime +import json +import logging +import time +import uuid +from unittest import mock + +from pamqp import body, header +from pamqp import commands as specification + +from rabbitpy import exceptions, exchange, message +from tests import helpers + +logging.basicConfig(level=logging.DEBUG) + + +class TestCreation(helpers.TestCase): + + def setUp(self): + super().setUp() + self.body = uuid.uuid4() + self.msg = message.Message(self.channel, self.body, opinionated=True) + + def test_channel_assignment(self): + self.assertEqual(self.msg.channel, self.channel) + + def test_message_body(self): + self.assertEqual(self.msg.body, self.body) + + def test_message_message_id_property_set(self): + self.assertIn('message_id', self.msg.properties) + + +class TestCreationWithDictBody(helpers.TestCase): + + def setUp(self): + super().setUp() + self.body = {'foo': str(uuid.uuid4())} + self.msg = message.Message(self.channel, self.body) + + def test_message_body(self): + self.assertEqual(self.msg.body, json.dumps(self.body)) + + def test_message_content_type_is_set(self): + self.assertEqual(self.msg.properties['content_type'], + 'application/json') + + +class TestCreationWithStructTimeTimestamp(helpers.TestCase): + + def setUp(self): + super().setUp() + self.msg = message.Message(self.channel, str(uuid.uuid4()), + {'timestamp': time.localtime()}) + + def test_message_timestamp_property_is_datetime(self): + self.assertIsInstance(self.msg.properties['timestamp'], + datetime.datetime) + + +class TestCreationWithFloatTimestamp(helpers.TestCase): + + def setUp(self): + super().setUp() + self.msg = message.Message(self.channel, str(uuid.uuid4()), + {'timestamp': time.time()}) + + def test_message_timestamp_property_is_datetime(self): + self.assertIsInstance(self.msg.properties['timestamp'], + datetime.datetime) + + +class TestCreationWithIntTimestamp(helpers.TestCase): + + def setUp(self): + super().setUp() + self.msg = message.Message(self.channel, str(uuid.uuid4()), + {'timestamp': int(time.time())}) + + def test_message_timestamp_property_is_datetime(self): + self.assertIsInstance(self.msg.properties['timestamp'], + datetime.datetime) + + +class TestCreationWithInvalidTimestampType(helpers.TestCase): + + def test_message_timestamp_property_is_datetime(self): + self.assertRaises(TypeError, + message.Message, + self.channel, + str(uuid.uuid4()), + {'timestamp': ['Ohai']}) + + +class TestCreationWithNoneTimestamp(helpers.TestCase): + + def setUp(self): + super().setUp() + self.msg = message.Message(self.channel, str(uuid.uuid4()), + {'timestamp': None}) + + def test_message_timestamp_property_is_datetime(self): + self.assertIsNone(self.msg.properties['timestamp']) + + +class TestCreationWithStrTimestamp(helpers.TestCase): + + def setUp(self): + super().setUp() + self.msg = message.Message(self.channel, str(uuid.uuid4()), + {'timestamp': str(int(time.time()))}) + + def test_message_timestamp_property_is_datetime(self): + self.assertIsInstance(self.msg.properties['timestamp'], + datetime.datetime) + + +class TestCreationWithDictBodyAndProperties(helpers.TestCase): + + def setUp(self): + super().setUp() + self.body = {'foo': str(uuid.uuid4())} + self.msg = message.Message(self.channel, self.body, {'app_id': 'foo'}) + + def test_message_body(self): + self.assertEqual(self.msg.body, json.dumps(self.body)) + + def test_message_content_type_is_set(self): + self.assertEqual(self.msg.properties['content_type'], + 'application/json') + + +class TestNonOpinionatedCreation(helpers.TestCase): + + def setUp(self): + super().setUp() + self.body = str(uuid.uuid4()) + self.msg = message.Message(self.channel, self.body) + + def test_message_body(self): + self.assertEqual(self.msg.body, self.body) + + def test_message_message_id_property_is_not_set(self): + self.assertNotIn('message_id', self.msg.properties) + + def test_message_timestamp_property_is_not_set(self): + self.assertNotIn('timestamp', self.msg.properties) + + +class TestWithPropertiesCreation(helpers.TestCase): + + def setUp(self): + super().setUp() + self.body = uuid.uuid4() + self.props = {'app_id': b'Foo', + 'content_type': b'application/json', + 'content_encoding': b'gzip', + 'correlation_id': str(uuid.uuid4()), + 'delivery_mode': 2, + 'expiration': int(time.time()) + 10, + 'headers': {'foo': 'bar'}, + 'message_id': str(uuid.uuid4()), + 'message_type': b'TestCreation', + 'priority': 9, + 'reply_to': b'none', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'user_id': b'guest'} + self.msg = message.Message(self.channel, self.body, dict(self.props)) + + def test_message_body(self): + self.assertEqual(self.msg.body, self.body) + + def test_message_properties_match(self): + self.assertDictEqual(self.msg.properties, self.props) + + +class TestInvalidPropertyHandling(helpers.TestCase): + + def test_invalid_property_raises_key_error(self): + self.assertRaises(KeyError, + message.Message, + self.channel, + str(uuid.uuid4()), {'invalid': True}) + + +class TestDeliveredMessageObject(helpers.TestCase): + + BODY = '{"foo": "bar", "val": 1}' + PROPERTIES = {'message_type': 'test'} + CONSUMER_TAG = 'ctag0' + DELIVERY_TAG = 100 + REDELIVERED = True + EXCHANGE = 'test-exchange' + ROUTING_KEY = 'test-routing-key' + + def setUp(self): + super().setUp() + self.method = specification.Basic.Deliver(self.CONSUMER_TAG, + self.DELIVERY_TAG, + self.REDELIVERED, + self.EXCHANGE, + self.ROUTING_KEY) + self.msg = message.Message(self.channel, self.BODY, self.PROPERTIES) + self.msg.method = self.method + self.msg.name = self.method.name + + def test_delivery_tag_property(self): + self.assertEqual(self.msg.delivery_tag, self.DELIVERY_TAG) + + def test_redelivered_property(self): + self.assertEqual(self.msg.redelivered, self.REDELIVERED) + + def test_routing_key_property(self): + self.assertEqual(self.msg.routing_key, self.ROUTING_KEY) + + def test_exchange_property(self): + self.assertEqual(self.msg.exchange, self.EXCHANGE) + + def test_json_body_value(self): + self.assertDictEqual(self.msg.json(), json.loads(self.BODY)) + + def test_ack_invokes_channel_write_frame(self): + with mock.patch.object(self.channel, 'write_frame') as write_frame: + self.msg.ack() + write_frame.assert_called_once() + + def test_ack_channel_write_frame_type(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.ack() + frame_value = wframe.mock_calls[0][1][0] + self.assertIsInstance(frame_value, specification.Basic.Ack) + + def test_ack_channel_write_frame_delivery_tag_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.ack() + frame_value = wframe.mock_calls[0][1][0] + self.assertEqual(frame_value.delivery_tag, + self.DELIVERY_TAG) + + def test_ack_channel_write_frame_multiple_false_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.ack() + frame_value = wframe.mock_calls[0][1][0] + self.assertFalse(frame_value.multiple) + + def test_ack_channel_write_frame_multiple_true_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.ack(True) + frame_value = wframe.mock_calls[0][1][0] + self.assertTrue(frame_value.multiple) + + def test_nack_invokes_channel_write_frame(self): + with mock.patch.object(self.channel, 'write_frame') as write_frame: + self.msg.nack() + write_frame.assert_called_once() + + def test_nack_channel_write_frame_type(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack() + frame_value = wframe.mock_calls[0][1][0] + self.assertIsInstance(frame_value, specification.Basic.Nack) + + def test_nack_channel_write_frame_delivery_tag_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack() + frame_value = wframe.mock_calls[0][1][0] + self.assertEqual(frame_value.delivery_tag, + self.DELIVERY_TAG) + + def test_nack_channel_write_frame_requeue_false_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack(requeue=False) + frame_value = wframe.mock_calls[0][1][0] + self.assertFalse(frame_value.requeue) + + def test_nack_channel_write_frame_requeue_true_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack(requeue=True) + frame_value = wframe.mock_calls[0][1][0] + self.assertTrue(frame_value.requeue) + + def test_nack_channel_write_frame_multiple_false_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack() + frame_value = wframe.mock_calls[0][1][0] + self.assertFalse(frame_value.multiple) + + def test_nack_channel_write_frame_multiple_true_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.nack(all_previous=True) + frame_value = wframe.mock_calls[0][1][0] + self.assertTrue(frame_value.multiple) + + def test_reject_invokes_channel_write_frame(self): + with mock.patch.object(self.channel, 'write_frame') as write_frame: + self.msg.reject() + write_frame.assert_called_once() + + def test_reject_channel_write_frame_type(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.reject() + frame_value = wframe.mock_calls[0][1][0] + self.assertIsInstance(frame_value, specification.Basic.Reject) + + def test_reject_channel_write_frame_delivery_tag_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.reject() + frame_value = wframe.mock_calls[0][1][0] + self.assertEqual(frame_value.delivery_tag, + self.DELIVERY_TAG) + + def test_reject_channel_write_frame_requeue_false_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.reject(requeue=False) + frame_value = wframe.mock_calls[0][1][0] + self.assertFalse(frame_value.requeue) + + def test_reject_channel_write_frame_requeue_true_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: + self.msg.reject(requeue=True) + frame_value = wframe.mock_calls[0][1][0] + self.assertTrue(frame_value.requeue) + + +class TestNonDeliveredMessageObject(helpers.TestCase): + + BODY = {'foo': str(uuid.uuid4()), + 'bar': 'baz', + 'qux': 1} + + def setUp(self): + super().setUp() + self.body = self.BODY + self.msg = message.Message(self.channel, self.body, {'app_id': 'foo'}) + + def test_ack_raises_action_exception(self): + self.assertRaises(exceptions.ActionException, self.msg.ack) + + def test_nack_raises_action_exception(self): + self.assertRaises(exceptions.ActionException, self.msg.nack) + + def test_reject_raises_action_exception(self): + self.assertRaises(exceptions.ActionException, self.msg.reject) + + def test_prune_invalid_properties_removes_bogus_property(self): + self.msg.properties['invalid'] = True + self.msg._prune_invalid_properties() + self.assertNotIn('invalid', self.msg.properties) + + def test_coerce_property_int_to_str(self): + self.msg.properties['expiration'] = 123 + self.msg._coerce_properties() + self.assertIsInstance(self.msg.properties['expiration'], bytes) + + def test_coerce_property_str_to_int(self): + self.msg.properties['priority'] = '9' + self.msg._coerce_properties() + self.assertIsInstance(self.msg.properties['priority'], int) + + def test_coerce_property_str_to_empty_dict(self): + self.msg.properties['headers'] = '9' + self.msg._coerce_properties() + self.assertDictEqual(self.msg.properties['headers'], {}) + + def test_coerce_property_str_timestamp(self): + self.msg.properties['timestamp'] = str(int(time.time())) + self.msg._coerce_properties() + self.assertIsInstance(self.msg.properties['timestamp'], + datetime.datetime) + + +class TestPublishing(helpers.TestCase): + + BODY = {'foo': str(uuid.uuid4()), + 'bar': 'baz', + 'qux': 1} + EXCHANGE = 'foo' + ROUTING_KEY = 'bar.baz' + + @mock.patch('rabbitpy.channel.Channel.write_frames') + def setUp(self, write_frames): + super().setUp() + self.write_frames = write_frames + self.msg = message.Message(self.channel, self.BODY, {'app_id': 'foo'}) + self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) + + def test_publish_invokes_write_frame_with_basic_publish(self): + self.assertIsInstance(self.write_frames.mock_calls[0][1][0][0], + specification.Basic.Publish) + + def test_publish_with_exchange_object(self): + _exchange = exchange.Exchange(self.channel, self.EXCHANGE) + with mock.patch('rabbitpy.channel.Channel.write_frames') as wframes: + self.msg.publish(_exchange, self.ROUTING_KEY) + self.assertEqual(wframes.mock_calls[0][1][0][0].exchange, + self.EXCHANGE) + + def test_publish_with_exchange_str(self): + self.assertEqual(self.write_frames.mock_calls[0][1][0][0].exchange, + self.EXCHANGE) + + def test_publish_routing_key_value(self): + self.assertEqual(self.write_frames.mock_calls[0][1][0][0].routing_key, + self.ROUTING_KEY) + + def test_publish_mandatory_false_value(self): + self.assertFalse(self.write_frames.mock_calls[0][1][0][0].mandatory) + + def test_publish_mandatory_true_value(self): + with mock.patch('rabbitpy.channel.Channel.write_frames') as wframes: + self.msg.publish(self.EXCHANGE, self.ROUTING_KEY, True) + self.assertTrue(wframes.mock_calls[0][1][0][0].mandatory) + + def test_publish_invokes_write_frame_with_content_header(self): + self.assertIsInstance(self.write_frames.mock_calls[0][1][0][1], + header.ContentHeader) + + def test_content_header_frame_body_size(self): + self.assertEqual(self.write_frames.mock_calls[0][1][0][1].body_size, + len(self.msg.body)) + + def test_content_header_frame_properties(self): + value = self.write_frames.mock_calls[0][1][0][1].properties + for key in self.msg.properties: + self.assertEqual(self.msg.properties[key], + getattr(value, key)) + + def test_publish_invokes_write_frame_with_body(self): + self.assertIsInstance(self.write_frames.mock_calls[0][1][0][2], + body.ContentBody) + + def test_content_body_value(self): + self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, + bytes(json.dumps(self.BODY).encode('utf-8'))) + + +class TestJSONDeserialization(helpers.TestCase): + + BODY = b'{"qux": 1, "foo": "d5525b9d", "bar": "baz"}' + + def setUp(self): + super().setUp() + self.expectation = json.loads(self.BODY.decode('utf-8')) + self.msg = message.Message(self.channel, self.BODY) + + def test_json_body(self): + self.assertDictEqual(self.msg.json(), self.expectation) + + +class TestPublishingUnicode(helpers.TestCase): + + try: + BODY = '☢'.decode('utf-8') + except AttributeError: + BODY = '☢' + EXCHANGE = 'foo' + ROUTING_KEY = 'bar.baz' + + @mock.patch('rabbitpy.channel.Channel.write_frames') + def setUp(self, write_frames): + super().setUp() + self.write_frames = write_frames + self.msg = message.Message(self.channel, self.BODY) + self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) + + def test_content_body_value(self): + self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, + self.BODY.encode('utf-8')) + + +class TestPublisherConfirms(helpers.TestCase): + + BODY = 'confirm-this' + EXCHANGE = 'foo' + ROUTING_KEY = 'bar.baz' + + @mock.patch('rabbitpy.channel.Channel.write_frames') + def setUp(self, write_frames): + super().setUp() + self.write_frames = write_frames + self.channel._publisher_confirms = True + self.channel.wait_for_confirmation = self._confirm_wait = mock.Mock() + self.msg = message.Message(self.channel, self.BODY) + + def test_confirm_ack_response_returns_true(self): + self._confirm_wait.return_value = specification.Basic.Ack() + self.assertTrue(self.msg.publish(self.EXCHANGE, self.ROUTING_KEY)) + + def test_confirm_nack_response_returns_false(self): + self._confirm_wait.return_value = specification.Basic.Nack() + self.assertFalse(self.msg.publish(self.EXCHANGE, self.ROUTING_KEY)) + + def test_confirm_other_raises(self): + self._confirm_wait.return_value = specification.Basic.Consume() + self.assertRaises(exceptions.UnexpectedResponseError, + self.msg.publish, self.EXCHANGE, self.ROUTING_KEY) diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..bc5a0fc --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,426 @@ +""" +Test the rabbitpy.amqp_queue classes + +""" +from unittest import mock + +from pamqp import commands as specification + +from rabbitpy import amqp_queue, exceptions, utils +from tests import helpers + + +class QueueInitializationTests(helpers.TestCase): + + def test_empty_queue_name(self): + queue = amqp_queue.Queue(self.channel) + self.assertEqual(queue.name, '') + + def test_invalid_queue_name(self): + self.assertRaises(ValueError, amqp_queue.Queue, self.channel, None) + + def test_auto_delete_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertFalse(queue.auto_delete) + + def test_auto_delete_true(self): + queue = amqp_queue.Queue(self.channel, auto_delete=True) + self.assertTrue(queue.auto_delete) + + def test_auto_delete_false(self): + queue = amqp_queue.Queue(self.channel, auto_delete=False) + self.assertFalse(queue.auto_delete) + + def test_auto_delete_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, None, None, None, 10) + + def test_durable_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertFalse(queue.durable) + + def test_durable_true(self): + queue = amqp_queue.Queue(self.channel, durable=True) + self.assertTrue(queue.durable) + + def test_durable_false(self): + queue = amqp_queue.Queue(self.channel, durable=False) + self.assertFalse(queue.durable) + + def test_durable_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, None, 'Foo') + + def test_exclusive_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertFalse(queue.exclusive) + + def test_exclusive_true(self): + queue = amqp_queue.Queue(self.channel, exclusive=True) + self.assertTrue(queue.exclusive) + + def test_exclusive_false(self): + queue = amqp_queue.Queue(self.channel, exclusive=False) + self.assertFalse(queue.exclusive) + + def test_exclusive_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, None, None, 'Bar') + + def test_expires_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertIsNone(queue.expires) + + def test_expires_named_value(self): + queue = amqp_queue.Queue(self.channel, expires=10) + self.assertEqual(queue.expires, 10) + self.assertIsInstance(queue.expires, int) + + def test_expires_positional_value(self): + queue = amqp_queue.Queue(self.channel, '', True, False, True, + None, None, 10) + self.assertEqual(queue.expires, 10) + self.assertIsInstance(queue.expires, int) + + def test_expires_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, '', True, False, True, None, None, 'Foo') + + def test_max_length_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertIsNone(queue.max_length) + + def test_max_length_named_value(self): + queue = amqp_queue.Queue(self.channel, max_length=10) + self.assertEqual(queue.max_length, 10) + self.assertIsInstance(queue.max_length, int) + + def test_max_length_positional_value(self): + queue = amqp_queue.Queue(self.channel, '', True, False, True, 10) + self.assertEqual(queue.max_length, 10) + self.assertIsInstance(queue.max_length, int) + + def test_max_length_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, '', True, False, True, 'Foo') + + def test_message_ttl_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertIsNone(queue.message_ttl) + + def test_message_ttl_value(self): + queue = amqp_queue.Queue(self.channel, message_ttl=10) + self.assertEqual(queue.message_ttl, 10) + self.assertIsInstance(queue.message_ttl, int) + + def test_message_ttl_positional_value(self): + queue = amqp_queue.Queue(self.channel, '', True, False, True, None, 10) + self.assertEqual(queue.message_ttl, 10) + self.assertIsInstance(queue.message_ttl, int) + + def test_message_ttl_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, + self.channel, '', True, False, True, None, 'Foo') + + def test_dlx_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertIsNone(queue.dead_letter_exchange) + + def test_dlx_value(self): + queue = amqp_queue.Queue(self.channel, dead_letter_exchange='dlx-name') + self.assertEqual(queue.dead_letter_exchange, 'dlx-name') + + def test_dlx_bytes(self): + queue = amqp_queue.Queue(self.channel, dead_letter_exchange=b'dlx-name') + self.assertIsInstance(queue.dead_letter_exchange, bytes) + + def test_dlx_str(self): + queue = amqp_queue.Queue(self.channel, dead_letter_exchange='dlx-name') + self.assertIsInstance(queue.dead_letter_exchange, str) + + @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') + def test_dlx_py2_unicode(self): + queue = amqp_queue.Queue(self.channel, + dead_letter_exchange='dlx-name') + self.assertIsInstance(queue.dead_letter_exchange, str) + + def test_message_dlx_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, + False, True, None, None, None, True) + + def test_dlr_default(self): + queue = amqp_queue.Queue(self.channel) + self.assertIsNone(queue.dead_letter_routing_key) + + def test_dlr_value(self): + queue = amqp_queue.Queue(self.channel, + dead_letter_routing_key='routing-key') + self.assertEqual(queue.dead_letter_routing_key, 'routing-key') + + def test_dlr_bytes(self): + queue = amqp_queue.Queue(self.channel, + dead_letter_routing_key=b'routing-key') + self.assertIsInstance(queue.dead_letter_routing_key, bytes) + + def test_dlr_str(self): + queue = amqp_queue.Queue(self.channel, + dead_letter_routing_key='routing-key') + self.assertIsInstance(queue.dead_letter_routing_key, str) + + @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') + def test_dlr_py2_unicode(self): + routing_key = 'routing-key' + queue = amqp_queue.Queue(self.channel, + dead_letter_routing_key=routing_key) + self.assertIsInstance(queue.dead_letter_routing_key, str) + + def test_dlr_validation(self): + self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, + False, True, None, None, None, None, True) + + @helpers.unittest.skipIf( + utils.PYPY, 'PyPy bails this due to improper __exit__ behavior') + def test_stop_consuming_raises_exception(self): + queue = amqp_queue.Queue(self.channel) + self.assertRaises(exceptions.NotConsumingError, queue.stop_consuming) + + +class QueueDeclareTests(helpers.TestCase): + + def test_default_declare(self): + obj = amqp_queue.Queue(self.channel) + expectation = {'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': '', + 'ticket': 0} + self.assertDictEqual(dict(obj._declare(False)), expectation) + + def test_default_declare_passive(self): + obj = amqp_queue.Queue(self.channel) + expectation = {'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': True, + 'queue': '', + 'ticket': 0} + self.assertDictEqual(dict(obj._declare(True)), expectation) + + def test_queue_name(self): + obj = amqp_queue.Queue(self.channel, 'my-queue') + expectation = {'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0} + self.assertDictEqual(dict(obj._declare(False)), expectation) + + def test_non_defaults(self): + obj = amqp_queue.Queue(self.channel, 'my-queue', False, True, True, + 100, 30000, 60000, 'dlx-name', 'dlrk') + expectation = {'arguments': {'x-expires': 60000, + 'x-max-length': 100, + 'x-message-ttl': 30000, + 'x-dead-letter-exchange': 'dlx-name', + 'x-dead-letter-routing-key': 'dlrk'}, + 'auto_delete': True, + 'durable': False, + 'exclusive': True, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0} + self.assertDictEqual(dict(obj._declare(False)), expectation) + + +class QueueAssignmentTests(helpers.TestCase): + + def setUp(self): + super().setUp() + self.queue = amqp_queue.Queue(self.channel) + + def test_auto_delete_assign_true(self): + self.queue.auto_delete = True + self.assertTrue(self.queue.auto_delete) + + def test_auto_delete_assign_false(self): + self.queue.auto_delete = False + self.assertFalse(self.queue.auto_delete) + + def test_auto_delete_assign_raises_type_error(self): + def assign_value(): + self.queue.auto_delete = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_durable_assign_true(self): + self.queue.durable = True + self.assertTrue(self.queue.durable) + + def test_durable_assign_false(self): + self.queue.durable = False + self.assertFalse(self.queue.durable) + + def test_durable_assign_raises_type_error(self): + def assign_value(): + self.queue.durable = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_exclusive_assign_true(self): + self.queue.exclusive = True + self.assertTrue(self.queue.exclusive) + + def test_exclusive_assign_false(self): + self.queue.exclusive = False + self.assertFalse(self.queue.exclusive) + + def test_exclusive_assign_raises_type_error(self): + def assign_value(): + self.queue.exclusive = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_expires_assign_value(self): + self.queue.expires = 100 + self.assertEqual(self.queue.expires, 100) + + def test_expires_assign_raises_type_error(self): + def assign_value(): + self.queue.expires = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_max_length_assign_value(self): + self.queue.max_length = 100 + self.assertEqual(self.queue.max_length, 100) + + def test_max_length_assign_raises_type_error(self): + def assign_value(): + self.queue.max_length = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_message_ttl_assign_value(self): + self.queue.message_ttl = 100 + self.assertEqual(self.queue.message_ttl, 100) + + def test_message_ttl_assign_raises_type_error(self): + def assign_value(): + self.queue.message_ttl = 'Hello' + self.assertRaises(ValueError, assign_value) + + def test_dead_letter_exchange_assign_value(self): + self.queue.dead_letter_exchange = 'abcd' + self.assertEqual(self.queue.dead_letter_exchange, 'abcd') + + def test_dead_letter_exchange_assign_raises_type_error(self): + def assign_value(): + self.queue.dead_letter_exchange = 1234 + self.assertRaises(ValueError, assign_value) + + def test_dead_letter_routing_key_assign_value(self): + self.queue.dead_letter_routing_key = 'abcd' + self.assertEqual(self.queue.dead_letter_routing_key, 'abcd') + + def test_dead_letter_routing_key_assign_raises_type_error(self): + def assign_value(): + self.queue.dead_letter_routing_key = 1234 + self.assertRaises(ValueError, assign_value) + + def test_arguments_assign_value(self): + self.queue.arguments = {'foo': 'bar'} + self.assertDictEqual(self.queue.arguments, {'foo': 'bar'}) + + def test_arguments_assign_raises_type_error(self): + def assign_value(): + self.queue.arguments = 1234 + self.assertRaises(ValueError, assign_value) + + +class WriteFrameTests(helpers.TestCase): + NAME = 'test' + DURABLE = True + EXCLUSIVE = True + AUTO_DELETE = False + MAX_LENGTH = 200 + MESSAGE_TTL = 60000 + EXPIRES = 10 + DEAD_LETTER_EXCHANGE = 'dlx' + DEAD_LETTER_ROUTING_KEY = 'dead' + + def setUp(self): + super().setUp() + self.queue = amqp_queue.Queue(self.channel, self.NAME, self.DURABLE, + self.EXCLUSIVE, self.AUTO_DELETE, + self.MAX_LENGTH, self.MESSAGE_TTL, + self.EXPIRES, self.DEAD_LETTER_EXCHANGE, + self.DEAD_LETTER_ROUTING_KEY) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_declare_invokes_write_frame_with_queue_declare(self, rpc): + self.queue.declare() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Declare) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_ha_declare_invokes_write_frame_with_queue_declare(self, rpc): + self.queue.ha_declare() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Declare) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_ha_declare_list_invokes_write_frame_with_queue_declare(self, rpc): + self.queue.ha_declare(['foo', 'bar']) + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Declare) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_ha_declare_list_sets_proper_attributes(self, rpc): + self.queue.ha_declare(['foo', 'bar']) + self.assertListEqual(self.queue.arguments['x-ha-nodes'], + ['foo', 'bar']) + self.assertEqual(self.queue.arguments['x-ha-policy'], 'nodes') + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_ha_declare_clears_ha_nodes(self, rpc): + self.queue.arguments['x-ha-nodes'] = ['foo', 'bar'] + self.queue.ha_declare() + self.assertNotIn('x-ha-nodes', self.queue.arguments) + self.assertEqual(self.queue.arguments['x-ha-policy'], 'all') + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_bind_invokes_write_frame_with_queue_bind(self, rpc): + self.queue.bind('foo', 'bar') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Bind) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_unbind_invokes_write_frame_with_queue_declare(self, rpc): + self.queue.unbind('foo', 'bar') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Unbind) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_unbind_with_obj_invokes_write_frame_with_queue_declare(self, rpc): + exchange = mock.Mock() + exchange.name = 'foo' + self.queue.unbind(exchange, 'bar') + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Unbind) + + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_unbind_invokes_write_frame_with_queue_delete(self, rpc): + self.queue.delete() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Delete) + + @mock.patch('rabbitpy.amqp_queue.Queue._rpc') + def test_purge_invokes_write_frame_with_queue_purge(self, rpc): + self.queue.purge() + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Queue.Purge) diff --git a/tests/test_tx.py b/tests/test_tx.py new file mode 100644 index 0000000..5a05c5a --- /dev/null +++ b/tests/test_tx.py @@ -0,0 +1,90 @@ +""" +Test the rabbitpy.tx classes + +""" +from unittest import mock + +from pamqp import commands as specification + +from rabbitpy import exceptions, tx +from tests import helpers + + +class TxTests(helpers.TestCase): + + def test_obj_creation_does_not_invoke_select(self): + with mock.patch('rabbitpy.tx.Tx.select') as select: + transaction = tx.Tx(self.channel) + self.assertFalse(transaction._selected) + select.assert_not_called() + + def test_enter_invokes_select(self): + with mock.patch('rabbitpy.tx.Tx.select') as select: + with tx.Tx(self.channel): + select.assert_called_once() + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_exit_invokes_commit(self, rpc): + rpc.return_value = specification.Tx.SelectOk + with mock.patch('rabbitpy.tx.Tx.select'): + with mock.patch('rabbitpy.tx.Tx.commit') as commit: + with tx.Tx(self.channel) as transaction: + transaction._selected = True + commit.assert_called_once() + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_exit_on_exception_invokes_commit_with_selected(self, rpc): + rpc.return_value = specification.Tx.SelectOk + with mock.patch('rabbitpy.tx.Tx.select'): + with mock.patch('rabbitpy.tx.Tx.rollback') as rollback: + try: + with tx.Tx(self.channel) as transaction: + transaction._selected = True + raise exceptions.AMQPChannelError() + except exceptions.AMQPChannelError: + pass + rollback.assert_called_once() + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_select_invokes_rpc_with_tx_select(self, rpc): + rpc.return_value = specification.Tx.CommitOk + with tx.Tx(self.channel): + pass + self.assertIsInstance(rpc.mock_calls[0][1][0], + specification.Tx.Select) + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_commit_invokes_rpc_with_tx_commit(self, rpc): + rpc.return_value = specification.Tx.SelectOk + obj = tx.Tx(self.channel) + obj.select() + rpc.return_value = specification.Tx.CommitOk + obj.commit() + self.assertIsInstance(rpc.mock_calls[1][1][0], + specification.Tx.Commit) + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_commit_raises_when_channel_closed(self, rpc): + obj = tx.Tx(self.channel) + obj.select() + rpc.side_effect = exceptions.ChannelClosedException + self.assertRaises(exceptions.NoActiveTransactionError, + obj.commit) + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_rollback_invokes_rpc_with_tx_rollback(self, rpc): + rpc.return_value = specification.Tx.SelectOk + obj = tx.Tx(self.channel) + obj.select() + rpc.return_value = specification.Tx.RollbackOk + obj.rollback() + self.assertIsInstance(rpc.mock_calls[1][1][0], + specification.Tx.Rollback) + + @mock.patch('rabbitpy.tx.Tx._rpc') + def test_rollback_raises_when_channel_closed(self, rpc): + obj = tx.Tx(self.channel) + obj.select() + rpc.side_effect = exceptions.ChannelClosedException + self.assertRaises(exceptions.NoActiveTransactionError, + obj.rollback) diff --git a/tests/utils_tests.py b/tests/utils_tests.py new file mode 100644 index 0000000..bd8a6f5 --- /dev/null +++ b/tests/utils_tests.py @@ -0,0 +1,64 @@ +""" +Test the rabbitpy utils module + +""" +import unittest +import sys + +from rabbitpy import utils + +# 3 Unicode Compatibility hack +unicode = str + + +class UtilsTestCase(unittest.TestCase): + + AMQP = 'amqp://guest:guest@localhost:5672/%2F?heartbeat_interval=1' + AMQPS = 'amqps://guest:guest@localhost:5672/%2F?heartbeat_interval=1' + + NETLOC = 'guest:guest@localhost:5672' + PATH = '/%2F' + PARAMS = '' + QUERY = 'heartbeat_interval=1' + FRAGMENT = '' + + def test_urlparse_amqp_scheme(self): + self.assertEqual(utils.urlparse(self.AMQP).scheme, 'amqp') + + def test_urlparse_amqps_scheme(self): + self.assertEqual(utils.urlparse(self.AMQPS).scheme, 'amqps') + + def test_urlparse_netloc(self): + self.assertEqual(utils.urlparse(self.AMQPS).netloc, self.NETLOC) + + def test_urlparse_url(self): + self.assertEqual(utils.urlparse(self.AMQPS).path, self.PATH) + + def test_urlparse_params(self): + self.assertEqual(utils.urlparse(self.AMQPS).params, self.PARAMS) + + def test_urlparse_query(self): + self.assertEqual(utils.urlparse(self.AMQPS).query, self.QUERY) + + def test_urlparse_fragment(self): + self.assertEqual(utils.urlparse(self.AMQPS).fragment, self.FRAGMENT) + + def test_parse_qs(self): + self.assertDictEqual(utils.parse_qs(self.QUERY), + {'heartbeat_interval': ['1']}) + + def test_is_string_str(self): + self.assertTrue(utils.is_string('Foo')) + + def test_is_string_bytes(self): + self.assertTrue(utils.is_string(b'Foo')) + + @unittest.skipIf(sys.version_info[0] == 3, 'No unicode obj in 3') + def test_is_string_unicode(self): + self.assertTrue(utils.is_string(unicode('Foo'))) + + def test_is_string_false_int(self): + self.assertFalse(utils.is_string(123)) + + def test_unqoute(self): + self.assertEqual(utils.unquote(self.PATH), '//') From 19272e0a88cfb257739ab06487887561670f5323 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 09:52:59 -0400 Subject: [PATCH 2/8] Fix ruff formatting, type annotations, and correctness issues flagged by CI and CodeRabbit - Apply ruff format and fix 3 auto-fixable lint errors (unused import, unsorted imports) across helpers.py, integration_tests.py, utils_tests.py - Fix invalid type annotation syntax in base.AMQPChannel.write_frame: replace `[base.Frame, heartbeat.Heartbeat]` with proper union syntax - Add missing name type validation in base.AMQPClass.__init__ so test_name_invalid passes (raises ValueError for non-string names) - Fix basic_consume return type annotation to Generator[message.Message, None, None] (it is a generator) - Fix basic_get to return the RPC result instead of discarding it - Remove extra False argument in basic_publish Message constructor call - Add thread-safe _stopped flag to Heartbeat to prevent timer recreation after stop() is called; use lock to guard start/stop/_maybe_send lifecycle - Fix tx.Tx.__exit__ to return False instead of re-raising exc_val, preserving original exception traceback; wrap rollback in try/except - Fix tx.commit/rollback to keep _selected=True on success rather than always clearing it, matching the documented transaction semantics - Fix create_queue in simple.py to skip name validation when queue_name is empty, restoring RabbitMQ auto-naming behaviour Co-Authored-By: Gavin M. Roy --- rabbitpy/amqp.py | 273 ++++++++++++++++++++-------------- rabbitpy/amqp_queue.py | 152 +++++++++++-------- rabbitpy/base.py | 123 +++++++++++----- rabbitpy/exchange.py | 86 +++++++---- rabbitpy/heartbeat.py | 42 ++++-- rabbitpy/message.py | 97 +++++++----- rabbitpy/simple.py | 148 +++++++++++-------- rabbitpy/tx.py | 34 +++-- rabbitpy/utils.py | 20 ++- tests/base_tests.py | 2 +- tests/channel_test.py | 22 ++- tests/helpers.py | 21 +-- tests/integration_tests.py | 222 ++++++++++++++++------------ tests/test_amqp.py | 2 +- tests/test_exchange.py | 36 ++--- tests/test_message.py | 222 +++++++++++++++------------- tests/test_queue.py | 292 +++++++++++++++++++++++++------------ tests/test_tx.py | 19 +-- tests/utils_tests.py | 9 +- 19 files changed, 1104 insertions(+), 718 deletions(-) diff --git a/rabbitpy/amqp.py b/rabbitpy/amqp.py index 600fdd7..dc175d9 100644 --- a/rabbitpy/amqp.py +++ b/rabbitpy/amqp.py @@ -3,6 +3,8 @@ """ +import typing + from pamqp import commands from rabbitpy import base, channel, exceptions, message, utils @@ -35,15 +37,16 @@ def basic_ack(self, delivery_tag: int = 0, multiple: bool = False) -> None: """ self._write_frame(commands.Basic.Ack(delivery_tag, multiple)) - def basic_consume(self, - queue: str = '', - consumer_tag: str = '', - no_local: bool = False, - no_ack: bool = False, - exclusive: bool = False, - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + def basic_consume( + self, + queue: str = '', + consumer_tag: str = '', + no_local: bool = False, + no_ack: bool = False, + exclusive: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> typing.Generator[message.Message, None, None]: """Start a queue consumer This method asks the server to start a "consumer", which is a transient @@ -74,8 +77,17 @@ def basic_consume(self, consumer_tag = self.consumer_tag self.channel.consumers[consumer_tag] = (self, no_ack) self._rpc( - commands.Basic.Consume(0, queue, consumer_tag, no_local, no_ack, - exclusive, nowait, arguments)) + commands.Basic.Consume( + 0, + queue, + consumer_tag, + no_local, + no_ack, + exclusive, + nowait, + arguments, + ) + ) self._consuming = True try: while self._consuming: @@ -90,9 +102,9 @@ def basic_consume(self, if self._consuming: self.basic_cancel(consumer_tag) - def basic_cancel(self, - consumer_tag: str = '', - nowait: bool = False) -> None: + def basic_cancel( + self, consumer_tag: str = '', nowait: bool = False + ) -> None: """End a queue consumer This method cancels a consumer. This does not affect already delivered @@ -112,7 +124,9 @@ def basic_cancel(self, self.channel.cancel_consumer(self, consumer_tag, nowait) self._consuming = False - def basic_get(self, queue: str = '', no_ack: bool = False) -> None: + def basic_get( + self, queue: str = '', no_ack: bool = False + ) -> 'message.Message | None': """Direct access to a queue This method provides direct access to the messages in a queue using a @@ -123,12 +137,14 @@ def basic_get(self, queue: str = '', no_ack: bool = False) -> None: :param no_ack: No acknowledgement needed """ - self._rpc(commands.Basic.Get(0, queue, no_ack)) - - def basic_nack(self, - delivery_tag: int = 0, - multiple: bool = False, - requeue: bool = True) -> None: + return self._rpc(commands.Basic.Get(0, queue, no_ack)) + + def basic_nack( + self, + delivery_tag: int = 0, + multiple: bool = False, + requeue: bool = True, + ) -> None: """Reject one or more incoming messages. This method allows a client to reject one or more incoming messages. It @@ -145,14 +161,15 @@ def basic_nack(self, """ self._write_frame(commands.Basic.Nack(delivery_tag, multiple, requeue)) - def basic_publish(self, - exchange: str = '', - routing_key: str = '', - body: str = '', - properties: dict | bytes | None = None, - mandatory: bool = False, - immediate: bool = False) \ - -> bool | None: + def basic_publish( + self, + exchange: str = '', + routing_key: str = '', + body: str = '', + properties: dict | bytes | None = None, + mandatory: bool = False, + immediate: bool = False, + ) -> bool | None: """Publish a message This method publishes a message to a specific exchange. The message @@ -168,14 +185,15 @@ def basic_publish(self, :param immediate: Request immediate delivery """ - msg = message.Message(self.channel, body, properties or {}, False, - False) + msg = message.Message(self.channel, body, properties or {}, False) return msg.publish(exchange, routing_key, mandatory, immediate) - def basic_qos(self, - prefetch_size: int = 0, - prefetch_count: int = 0, - global_flag: bool = False) -> None: + def basic_qos( + self, + prefetch_size: int = 0, + prefetch_count: int = 0, + global_flag: bool = False, + ) -> None: """Specify quality of service This method requests a specific quality of service. The QoS can be @@ -191,11 +209,12 @@ def basic_qos(self, """ self._rpc( - commands.Basic.Qos(prefetch_size, prefetch_count, global_flag)) + commands.Basic.Qos(prefetch_size, prefetch_count, global_flag) + ) - def basic_reject(self, - delivery_tag: int = 0, - requeue: bool = True) -> None: + def basic_reject( + self, delivery_tag: int = 0, requeue: bool = True + ) -> None: """Reject an incoming message This method allows a client to reject a message. It can be used to @@ -227,16 +246,17 @@ def confirm_select(self) -> None: """ self._rpc(commands.Confirm.Select()) - def exchange_declare(self, - exchange: str = '', - exchange_type: str = 'direct', - passive: bool = False, - durable: bool = False, - auto_delete: bool = False, - internal: bool = False, - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + def exchange_declare( + self, + exchange: str = '', + exchange_type: str = 'direct', + passive: bool = False, + durable: bool = False, + auto_delete: bool = False, + internal: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Verify exchange exists, create if needed This method creates an exchange if it does not already exist, and if @@ -254,14 +274,22 @@ def exchange_declare(self, """ self._rpc( - commands.Exchange.Declare(0, exchange, exchange_type, passive, - durable, auto_delete, internal, nowait, - arguments)) - - def exchange_delete(self, - exchange: str = '', - if_unused: bool = False, - nowait: bool = False) -> None: + commands.Exchange.Declare( + 0, + exchange, + exchange_type, + passive, + durable, + auto_delete, + internal, + nowait, + arguments, + ) + ) + + def exchange_delete( + self, exchange: str = '', if_unused: bool = False, nowait: bool = False + ) -> None: """Delete an exchange This method deletes an exchange. When an exchange is deleted all queue @@ -274,13 +302,14 @@ def exchange_delete(self, """ self._rpc(commands.Exchange.Delete(0, exchange, if_unused, nowait)) - def exchange_bind(self, - destination: str = '', - source: str = '', - routing_key: str = '', - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + def exchange_bind( + self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Bind exchange to an exchange. This method binds an exchange to an exchange. @@ -293,16 +322,19 @@ def exchange_bind(self, """ self._rpc( - commands.Exchange.Bind(0, destination, source, routing_key, nowait, - arguments)) - - def exchange_unbind(self, - destination: str = '', - source: str = '', - routing_key: str = '', - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + commands.Exchange.Bind( + 0, destination, source, routing_key, nowait, arguments + ) + ) + + def exchange_unbind( + self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Unbind an exchange from an exchange. This method unbinds an exchange from an exchange. @@ -315,16 +347,19 @@ def exchange_unbind(self, """ self._rpc( - commands.Exchange.Unbind(0, destination, source, routing_key, - nowait, arguments)) - - def queue_bind(self, - queue: str = '', - exchange: str = '', - routing_key: str = '', - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + commands.Exchange.Unbind( + 0, destination, source, routing_key, nowait, arguments + ) + ) + + def queue_bind( + self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Bind queue to an exchange This method binds a queue to an exchange. Until a queue is bound it @@ -340,18 +375,21 @@ def queue_bind(self, """ self._rpc( - commands.Queue.Bind(0, queue, exchange, routing_key, nowait, - arguments)) - - def queue_declare(self, - queue: str = '', - passive: bool = False, - durable: bool = False, - exclusive: bool = False, - auto_delete: bool = False, - nowait: bool = False, - arguments: dict | None = None) \ - -> None: + commands.Queue.Bind( + 0, queue, exchange, routing_key, nowait, arguments + ) + ) + + def queue_declare( + self, + queue: str = '', + passive: bool = False, + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Declare queue, create if needed This method creates or checks a queue. When creating a new queue the @@ -368,14 +406,25 @@ def queue_declare(self, """ self._rpc( - commands.Queue.Declare(0, queue, passive, durable, exclusive, - auto_delete, nowait, arguments)) - - def queue_delete(self, - queue: str = '', - if_unused: bool = False, - if_empty: bool = False, - nowait: bool = False) -> None: + commands.Queue.Declare( + 0, + queue, + passive, + durable, + exclusive, + auto_delete, + nowait, + arguments, + ) + ) + + def queue_delete( + self, + queue: str = '', + if_unused: bool = False, + if_empty: bool = False, + nowait: bool = False, + ) -> None: """Delete a queue This method deletes a queue. When a queue is deleted any pending @@ -402,12 +451,13 @@ def queue_purge(self, queue: str = '', nowait: bool = False) -> None: """ self._rpc(commands.Queue.Purge(0, queue, nowait)) - def queue_unbind(self, - queue: str = '', - exchange: str = '', - routing_key: str = '', - arguments: dict | None = None) \ - -> None: + def queue_unbind( + self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + arguments: dict | None = None, + ) -> None: """Unbind a queue from an exchange This method unbinds a queue from an exchange. @@ -419,7 +469,8 @@ def queue_unbind(self, """ self._rpc( - commands.Queue.Unbind(0, queue, exchange, routing_key, arguments)) + commands.Queue.Unbind(0, queue, exchange, routing_key, arguments) + ) def tx_select(self) -> None: """Select standard transaction mode diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index dd0bf63..855b247 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -26,6 +26,7 @@ message.ack() """ + import logging import typing @@ -62,6 +63,7 @@ class Queue(base.AMQPClass): :raises: :py:exc:`~rabbitpy.exceptions.RemoteCancellationException` """ + arguments = {} auto_delete = False dead_letter_exchange = None @@ -73,18 +75,20 @@ class Queue(base.AMQPClass): message_ttl = None # pylint: disable=too-many-arguments - def __init__(self, - channel: chan.Channel, - name: str = '', - durable: bool = False, - exclusive: bool = False, - auto_delete: bool = False, - max_length: int | None = None, - message_ttl: int | None = None, - expires: int | None = None, - dead_letter_exchange: str | None = None, - dead_letter_routing_key: str | None = None, - arguments: dict | None = None): + def __init__( + self, + channel: chan.Channel, + name: str = '', + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None, + ): """Create a new Queue object instance. Only the :py:class:`rabbitpy.Channel` object is required. @@ -144,25 +148,34 @@ def __setattr__(self, name: str, value: typing.Any) -> None: """ if value is not None: - if (name in ['auto_delete', 'durable', 'exclusive'] - and not isinstance(value, bool)): + if name in [ + 'auto_delete', + 'durable', + 'exclusive', + ] and not isinstance(value, bool): raise ValueError(f'{name} must be True or False') - elif (name in ['max_length', 'message_ttl', 'expires'] - and not isinstance(value, int)): + elif name in [ + 'max_length', + 'message_ttl', + 'expires', + ] and not isinstance(value, int): raise ValueError(f'{name} must be an int') - elif (name in [ - 'consumer_tag', 'dead_letter_exchange', - 'dead_letter_routing_key' - ] and not isinstance(value, (bytes, str))): + elif name in [ + 'consumer_tag', + 'dead_letter_exchange', + 'dead_letter_routing_key', + ] and not isinstance(value, (bytes, str)): raise ValueError(f'{name} must be a str or bytes') elif name == 'arguments' and not isinstance(value, dict): raise ValueError('arguments must be a dict') super().__setattr__(name, value) - def bind(self, - source: exchange.ExchangeTypes, - routing_key: str | None = None, - arguments: dict | None = None) -> bool: + def bind( + self, + source: exchange.ExchangeTypes, + routing_key: str | None = None, + arguments: dict | None = None, + ) -> bool: """Bind the queue to the specified exchange or routing key. :param source: The exchange to bind to @@ -172,19 +185,22 @@ def bind(self, """ if hasattr(source, 'name'): source = source.name - frame = commands.Queue.Bind(queue=self.name, - exchange=source, - routing_key=routing_key or '', - arguments=arguments) + frame = commands.Queue.Bind( + queue=self.name, + exchange=source, + routing_key=routing_key or '', + arguments=arguments, + ) response = self._rpc(frame) return isinstance(response, commands.Queue.BindOk) - def consume(self, - no_ack: bool = False, - prefetch: int | None = None, - priority: int | None = None, - consumer_tag: str | None = None) \ - -> typing.Generator[message.Message, None, None]: + def consume( + self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None, + consumer_tag: str | None = None, + ) -> typing.Generator[message.Message, None, None]: """Consume messages from the queue as a :py:class:`generator`: .. code:: python @@ -246,12 +262,12 @@ def delete(self, if_unused: bool = False, if_empty: bool = False) -> None: """ self._rpc( - commands.Queue.Delete(queue=self.name, - if_unused=if_unused, - if_empty=if_empty)) + commands.Queue.Delete( + queue=self.name, if_unused=if_unused, if_empty=if_empty + ) + ) - def get(self, acknowledge: bool = True) \ - -> message.Message | None: + def get(self, acknowledge: bool = True) -> message.Message | None: """Request a single message from RabbitMQ using the Basic.Get AMQP command. @@ -266,11 +282,11 @@ def get(self, acknowledge: bool = True) \ """ self._write_frame( - commands.Basic.Get(queue=self.name, no_ack=not acknowledge)) + commands.Basic.Get(queue=self.name, no_ack=not acknowledge) + ) return self.channel.get_message() - def ha_declare(self, nodes: list[str] | None = None) \ - -> tuple[int, int]: + def ha_declare(self, nodes: list[str] | None = None) -> tuple[int, int]: """Declare the queue as highly available, passing in a list of nodes the queue should live on. If no nodes are passed, the queue will be declared across all nodes in the cluster. @@ -308,9 +324,9 @@ def stop_consuming(self) -> None: self.channel.cancel_consumer(self) self.consuming = False - def unbind(self, - source: exchange.ExchangeTypes, - routing_key: str | None = None) -> None: + def unbind( + self, source: exchange.ExchangeTypes, routing_key: str | None = None + ) -> None: """Unbind queue from the specified exchange where it is bound the routing key. If routing key is None, use the queue name. @@ -322,9 +338,10 @@ def unbind(self, source = source.name routing_key = routing_key or self.name self._rpc( - commands.Queue.Unbind(queue=self.name, - exchange=source, - routing_key=routing_key)) + commands.Queue.Unbind( + queue=self.name, exchange=source, routing_key=routing_key + ) + ) def _declare(self, passive: bool = False) -> commands.Queue.Declare: """Return a commands.Queue.Declare class pre-composed for the rpc @@ -344,24 +361,35 @@ def _declare(self, passive: bool = False) -> commands.Queue.Declare: if self.dead_letter_exchange: arguments['x-dead-letter-exchange'] = self.dead_letter_exchange if self.dead_letter_routing_key: - arguments['x-dead-letter-routing-key'] = \ + arguments['x-dead-letter-routing-key'] = ( self.dead_letter_routing_key + ) LOGGER.debug( 'Declaring Queue %s, durable=%s, passive=%s, ' - 'exclusive=%s, auto_delete=%s, arguments=%r', self.name, - self.durable, passive, self.exclusive, self.auto_delete, arguments) - return commands.Queue.Declare(queue=self.name, - durable=self.durable, - passive=passive, - exclusive=self.exclusive, - auto_delete=self.auto_delete, - arguments=arguments) - - def _register_consumer(self, - no_ack: bool = False, - prefetch: int | None = None, - priority: int | None = None) -> None: + 'exclusive=%s, auto_delete=%s, arguments=%r', + self.name, + self.durable, + passive, + self.exclusive, + self.auto_delete, + arguments, + ) + return commands.Queue.Declare( + queue=self.name, + durable=self.durable, + passive=passive, + exclusive=self.exclusive, + auto_delete=self.auto_delete, + arguments=arguments, + ) + + def _register_consumer( + self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None, + ) -> None: """Start consuming on the channel, optionally setting the prefect count :param no_ack: Do not require acknowledgements diff --git a/rabbitpy/base.py b/rabbitpy/base.py index 546a65e..114fba2 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -2,6 +2,7 @@ Base classes for various parts of rabbitpy """ + import logging import queue import socket @@ -19,13 +20,14 @@ LOGGER = logging.getLogger(__name__) + class Interrupt(typing.TypedDict): event: threading.Event callback: typing.Callable | None args: typing.Iterable[typing.Any] | None -FrameTypes = typing.Union[str, type[base.Frame], - list[str | type[base.Frame]]] + +FrameTypes = typing.Union[str, type[base.Frame], list[str | type[base.Frame]]] class ChannelWriter: # pylint: disable=too-few-public-methods @@ -39,8 +41,15 @@ class ChannelWriter: # pylint: disable=too-few-public-methods def __init__(self, channel: chan.Channel): self.channel = channel - def _rpc(self, frame_value: base.Frame) \ - -> base.Frame | message.Message | commands.Basic.GetOk | commands.Basic.GetEmpty | commands.Queue.DeclareOk: + def _rpc( + self, frame_value: base.Frame + ) -> ( + base.Frame + | message.Message + | commands.Basic.GetOk + | commands.Basic.GetEmpty + | commands.Queue.DeclareOk + ): """Execute the RPC command for the frame. :param frame_value: The frame to send @@ -73,8 +82,11 @@ def __init__(self, channel: chan.Channel, name: str): """ super().__init__(channel) from rabbitpy import channel as chan_mod # local import to avoid cycle + if not isinstance(channel, chan_mod.Channel): raise ValueError('channel must be a valid rabbitpy Channel object') + if not isinstance(name, str): + raise ValueError('name must be a string') self.name = name @@ -83,6 +95,7 @@ class StatefulObject(utils.DebuggingOptimizationMixin): connection and channel. """ + CLOSED = 0 CLOSING = 1 OPEN = 2 @@ -105,8 +118,11 @@ def _set_state(self, value: int) -> None: if value not in self.STATES.keys(): raise ValueError(f'Invalid state value: {value!r}') if self._is_debugging: - LOGGER.debug('%s setting state to %r', self.__class__.__name__, - self.STATES[value]) + LOGGER.debug( + '%s setting state to %r', + self.__class__.__name__, + self.STATES[value], + ) self._state = value @property @@ -142,16 +158,19 @@ def state_description(self) -> str: class AMQPChannel(StatefulObject): """Base AMQP Channel Object""" + CLOSE_REQUEST_FRAME = commands.Channel.Close DEFAULT_CLOSE_CODE = 200 DEFAULT_CLOSE_REASON = 'Normal Shutdown' REMOTE_CLOSED = 0x04 - def __init__(self, - exception_queue: queue.Queue, - write_trigger: socket.socket, - connection: conn.Connection, - blocking_read: bool = False): + def __init__( + self, + exception_queue: queue.Queue, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False, + ): super().__init__() if blocking_read: LOGGER.debug('Initialized with blocking read') @@ -159,7 +178,7 @@ def __init__(self, self._interrupt: Interrupt = { 'event': threading.Event(), 'callback': None, - 'args': None + 'args': None, } self._channel_id: int | None = None self._connection: conn.Connection = connection @@ -181,12 +200,17 @@ def close(self) -> None: return if self.closed: - LOGGER.debug('AMQPChannel %i close invoked and already closed', - self._channel_id) + LOGGER.debug( + 'AMQPChannel %i close invoked and already closed', + self._channel_id, + ) return - LOGGER.debug('Channel %i close invoked while %s', self._channel_id, - self.state_description) + LOGGER.debug( + 'Channel %i close invoked while %s', + self._channel_id, + self.state_description, + ) # Make sure there are no RPC frames pending self._check_for_pending_frames() @@ -196,15 +220,19 @@ def close(self) -> None: frame_value = self._build_close_frame() if self._is_debugging: - LOGGER.debug('Channel %i Waiting for a valid response for %s', - self._channel_id, frame_value.name) + LOGGER.debug( + 'Channel %i Waiting for a valid response for %s', + self._channel_id, + frame_value.name, + ) self.rpc(frame_value) if self._is_debugging: LOGGER.debug('Channel #%i closed', self._channel_id) self._set_state(self.CLOSED) - def rpc(self, frame_value: base.Frame) \ - -> base.Frame | commands.Queue.DeclareOk: + def rpc( + self, frame_value: base.Frame + ) -> base.Frame | commands.Queue.DeclareOk: """Send an RPC command to the remote server. This should not be directly invoked. @@ -226,7 +254,7 @@ def wait_for_confirmation(self) -> base.Frame: """ return self._wait_on_frame([commands.Basic.Ack, commands.Basic.Nack]) - def write_frame(self, value: [base.Frame, heartbeat.Heartbeat]) -> None: + def write_frame(self, value: base.Frame | heartbeat.Heartbeat) -> None: """Put the frame in the write queue for the IOWriter object to write to the socket when it can. This should not be directly invoked. @@ -249,8 +277,9 @@ def write_frames(self, frames: list[base.Frame]) -> None: """ if self._can_write(): if self._is_debugging: - LOGGER.debug('Writing frames: %r', - [frame.name for frame in frames]) + LOGGER.debug( + 'Writing frames: %r', [frame.name for frame in frames] + ) with self._write_lock: for frame in frames: self._write_queue.put((self._channel_id, frame)) @@ -258,8 +287,9 @@ def write_frames(self, frames: list[base.Frame]) -> None: def _build_close_frame(self) -> commands.Channel.Close: """Return the proper close frame for this object.""" - return self.CLOSE_REQUEST_FRAME(self.DEFAULT_CLOSE_CODE, - self.DEFAULT_CLOSE_REASON) + return self.CLOSE_REQUEST_FRAME( + self.DEFAULT_CLOSE_CODE, self.DEFAULT_CLOSE_REASON + ) def _can_write(self) -> bool: self._check_for_exceptions() @@ -302,8 +332,9 @@ def _force_close(self) -> None: if self._is_debugging: LOGGER.debug('Channel #%i closed', self._channel_id) - def _interrupt_wait_on_frame(self, callback: typing.Callable, - *args) -> None: + def _interrupt_wait_on_frame( + self, callback: typing.Callable, *args + ) -> None: """Invoke to interrupt the current self._wait_on_frame blocking loop in order to allow for a flow such as waiting on a full message while consuming. Will wait until the ``_wait_on_frame_interrupt`` is cleared @@ -344,15 +375,18 @@ def _on_remote_close(self, value: commands.Channel.Close) -> None: """ self._set_state(self.REMOTE_CLOSED) if value.reply_code in exceptions.AMQP: - LOGGER.error('Received remote close (%s): %s', value.reply_code, - value.reply_text) + LOGGER.error( + 'Received remote close (%s): %s', + value.reply_code, + value.reply_text, + ) raise exceptions.AMQP[value.reply_code](value) else: raise exceptions.RemoteClosedChannelException( - self._channel_id, value.reply_code, value.reply_text) + self._channel_id, value.reply_code, value.reply_text + ) - def _read_from_queue(self) \ - -> base.Frame | commands.Basic.Deliver | None: + def _read_from_queue(self) -> base.Frame | commands.Basic.Deliver | None: """Check to see if a frame is in the queue and if so, return it""" if self._can_write() and not self.closing and self.blocking_read: if self._is_debugging: @@ -361,7 +395,7 @@ def _read_from_queue(self) \ self._read_queue.task_done() else: try: - value = self._read_queue.get(True, .1) + value = self._read_queue.get(True, 0.1) self._read_queue.task_done() except queue.Empty: value = None @@ -377,8 +411,9 @@ def _trigger_write(self) -> None: except OSError: pass - def _validate_frame_type(self, frame_value: base.Frame, - frame_type: FrameTypes) -> bool: + def _validate_frame_type( + self, frame_value: base.Frame, frame_type: FrameTypes + ) -> bool: """Validate the frame value against the frame type. The frame type can be an individual frame type or a list of frame types. @@ -403,8 +438,15 @@ def _validate_frame_type(self, frame_value: base.Frame, return frame_value.name == frame_type.name return False - def _wait_on_frame(self, frame_type: FrameTypes) \ - -> base.Frame | commands.Basic.Deliver | commands.Queue.DeclareOk | header.ContentHeader | body.ContentBody: + def _wait_on_frame( + self, frame_type: FrameTypes + ) -> ( + base.Frame + | commands.Basic.Deliver + | commands.Queue.DeclareOk + | header.ContentHeader + | body.ContentBody + ): """Read from the queue, blocking until a result is returned. An individual frame type or a list of frame types can be passed in to wait for specific frame types. If there is no match on the frame retrieved @@ -421,8 +463,11 @@ def _wait_on_frame(self, frame_type: FrameTypes) \ LOGGER.debug('Waiting on %r frame(s)', frame_type) start_state = self.state self._waiting = True - while (start_state == self.state and not self.closed - and not self._connection.closed): + while ( + start_state == self.state + and not self.closed + and not self._connection.closed + ): value = self._read_from_queue() if value is not None: self._check_for_rpc_request(value) diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index 2f186d6..d9df8a4 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -8,6 +8,7 @@ * :py:class:`TopicExchange` """ + import logging import typing @@ -18,9 +19,15 @@ LOGGER = logging.getLogger(__name__) -ExchangeTypes = typing.Union[str, '_Exchange', 'Exchange', 'DirectExchange', - 'FanoutExchange', 'HeadersExchange', - 'TopicExchange'] +ExchangeTypes = typing.Union[ + str, + '_Exchange', + 'Exchange', + 'DirectExchange', + 'FanoutExchange', + 'HeadersExchange', + 'TopicExchange', +] class _Exchange(base.AMQPClass): @@ -34,26 +41,29 @@ class _Exchange(base.AMQPClass): :param arguments: Optional key/value arguments """ + durable = False arguments = {} auto_delete = False type = 'direct' - def __init__(self, - channel: chan.Channel, - name: str, - durable: bool = False, - auto_delete: bool = False, - arguments: dict | None = None): + def __init__( + self, + channel: chan.Channel, + name: str, + durable: bool = False, + auto_delete: bool = False, + arguments: dict | None = None, + ): """Create a new instance of the exchange object.""" super().__init__(channel, name) self.durable = durable self.auto_delete = auto_delete self.arguments = arguments or {} - def bind(self, - source: ExchangeTypes, - routing_key: str | None = None) -> None: + def bind( + self, source: ExchangeTypes, routing_key: str | None = None + ) -> None: """Bind to another exchange with the routing key. :param source: The exchange to bind to @@ -64,7 +74,9 @@ def bind(self, source = source.name self._rpc( commands.Exchange.Bind( - destination=self.name, source=source, routing_key=routing_key)) + destination=self.name, source=source, routing_key=routing_key + ) + ) def declare(self, passive: bool = False) -> None: """Declare the exchange with RabbitMQ. If passive is True and the @@ -75,9 +87,14 @@ def declare(self, passive: bool = False) -> None: """ self._rpc( commands.Exchange.Declare( - exchange=self.name, exchange_type=self.type, - durable=self.durable, passive=passive, - auto_delete=self.auto_delete, arguments=self.arguments)) + exchange=self.name, + exchange_type=self.type, + durable=self.durable, + passive=passive, + auto_delete=self.auto_delete, + arguments=self.arguments, + ) + ) def delete(self, if_unused: bool = False) -> None: """Delete the exchange from RabbitMQ. @@ -86,12 +103,12 @@ def delete(self, if_unused: bool = False) -> None: """ self._rpc( - commands.Exchange.Delete( - exchange=self.name, if_unused=if_unused)) + commands.Exchange.Delete(exchange=self.name, if_unused=if_unused) + ) - def unbind(self, - source: ExchangeTypes, - routing_key: str | None = None) -> None: + def unbind( + self, source: ExchangeTypes, routing_key: str | None = None + ) -> None: """Unbind the exchange from the source exchange with the routing key. If routing key is None, use the queue or exchange name. @@ -103,7 +120,9 @@ def unbind(self, source = source.name self._rpc( commands.Exchange.Unbind( - destination=self.name, source=source, routing_key=routing_key)) + destination=self.name, source=source, routing_key=routing_key + ) + ) class Exchange(_Exchange): @@ -119,17 +138,18 @@ class Exchange(_Exchange): """ - def __init__(self, - channel: chan.Channel, - name: str, - exchange_type: str = 'direct', - durable: bool = False, - auto_delete: bool = False, - arguments: bool | None = None): + def __init__( + self, + channel: chan.Channel, + name: str, + exchange_type: str = 'direct', + durable: bool = False, + auto_delete: bool = False, + arguments: bool | None = None, + ): """Create a new instance of the exchange object.""" self.type = exchange_type - super().__init__( - channel, name, durable, auto_delete, arguments) + super().__init__(channel, name, durable, auto_delete, arguments) class DirectExchange(_Exchange): @@ -143,6 +163,7 @@ class DirectExchange(_Exchange): :param arguments: Optional key/value arguments """ + type = 'direct' @@ -157,6 +178,7 @@ class FanoutExchange(_Exchange): :param arguments: Optional key/value arguments """ + type = 'fanout' @@ -171,6 +193,7 @@ class HeadersExchange(_Exchange): :param arguments: Optional key/value arguments """ + type = 'headers' @@ -185,4 +208,5 @@ class TopicExchange(_Exchange): :param arguments: Optional key/value arguments """ + type = 'topic' diff --git a/rabbitpy/heartbeat.py b/rabbitpy/heartbeat.py index 0c0934c..0439c19 100644 --- a/rabbitpy/heartbeat.py +++ b/rabbitpy/heartbeat.py @@ -3,6 +3,7 @@ configured interval. """ + import logging import threading @@ -19,15 +20,19 @@ class Heartbeat: :param channel0: The channel that the heartbeat is sent over. """ - def __init__(self, - io: rabbitpy_io.IO, - channel0: rabbitpy_channel0.Channel0, - interval: float): + + def __init__( + self, + io: rabbitpy_io.IO, + channel0: rabbitpy_channel0.Channel0, + interval: float, + ): self._channel0 = channel0 self._interval = float(interval) / 2.0 self._io = io self._last_written = self._io.bytes_written self._lock = threading.Lock() + self._stopped = False self._timer: threading.Timer | None = None def start(self) -> None: @@ -35,21 +40,26 @@ def start(self) -> None: if not self._interval: LOGGER.debug('Heartbeats are disabled, not starting') return - self._start_timer() + with self._lock: + self._stopped = False + self._start_timer() LOGGER.debug( 'Heartbeat started, ensuring data is written every %.2f seconds', - self._interval) + self._interval, + ) def stop(self) -> None: """Stop the heartbeat checker""" - if self._timer: - self._timer.cancel() - self._timer = None + with self._lock: + self._stopped = True + if self._timer: + self._timer.cancel() + self._timer = None def _start_timer(self) -> None: """Create a new thread timer, destroying the last if it existed.""" if self._timer: - del self._timer + self._timer.cancel() self._timer = threading.Timer(self._interval, self._maybe_send) self._timer.daemon = True self._timer.start() @@ -60,9 +70,13 @@ def _maybe_send(self) -> None: have been written. """ + with self._lock: + if self._stopped: + return if not self._io.bytes_written - self._last_written: self._channel0.send_heartbeat() - self._lock.acquire(True) - self._last_written = self._io.bytes_written - self._lock.release() - self._start_timer() + with self._lock: + if self._stopped: + return + self._last_written = self._io.bytes_written + self._start_timer() diff --git a/rabbitpy/message.py b/rabbitpy/message.py index 6cabd7d..ca48d49 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -5,6 +5,7 @@ acknowledging it. """ + import datetime import json import logging @@ -24,6 +25,7 @@ class Properties(commands.Basic.Properties): """Proxy class for :py:class:`pamqp.specification.Basic.Properties`""" + pass @@ -77,14 +79,17 @@ class Message(base.AMQPClass): :raises KeyError: Raised when an invalid property is passed in """ + method = None name = 'Message' - def __init__(self, - channel: chan.Channel, - body_value: str | bytes | memoryview | dict | list, - properties: dict | None = None, - opinionated: bool = False): + def __init__( + self, + channel: chan.Channel, + body_value: str | bytes | memoryview | dict | list, + properties: dict | None = None, + opinionated: bool = False, + ): """Create a new instance of the Message object.""" super().__init__(channel, 'Message') @@ -107,8 +112,9 @@ def __init__(self, # Enforce datetime timestamps if 'timestamp' in self.properties: - self.properties['timestamp'] = \ - self._as_datetime(self.properties['timestamp']) + self.properties['timestamp'] = self._as_datetime( + self.properties['timestamp'] + ) # Don't let invalid property keys in if self._invalid_properties: @@ -155,9 +161,11 @@ def ack(self, all_previous: bool = False) -> None: """ if not self.method: raise exceptions.ActionException( - 'Can not ack non-received message') - basic_ack = commands.Basic.Ack(self.method.delivery_tag, - multiple=all_previous) + 'Can not ack non-received message' + ) + basic_ack = commands.Basic.Ack( + self.method.delivery_tag, multiple=all_previous + ) self.channel.write_frame(basic_ack) def json(self) -> bytes: @@ -179,10 +187,11 @@ def nack(self, requeue: bool = False, all_previous: bool = False) -> None: """ if not self.method: raise exceptions.ActionException( - 'Can not nack non-received message') - basic_nack = commands.Basic.Nack(self.method.delivery_tag, - requeue=requeue, - multiple=all_previous) + 'Can not nack non-received message' + ) + basic_nack = commands.Basic.Nack( + self.method.delivery_tag, requeue=requeue, multiple=all_previous + ) self.channel.write_frame(basic_nack) def pprint(self, properties: bool = False) -> None: # pragma: no cover @@ -194,11 +203,13 @@ def pprint(self, properties: bool = False) -> None: # pragma: no cover if properties: pass - def publish(self, - exchange: exc.ExchangeTypes, - routing_key: str = '', - mandatory: bool = False, - immediate: bool = False) -> bool | None: + def publish( + self, + exchange: exc.ExchangeTypes, + routing_key: str = '', + mandatory: bool = False, + immediate: bool = False, + ) -> bool | None: """Publish the message to the exchange with the specified routing key. @@ -221,16 +232,21 @@ def publish(self, payload = utils.maybe_utf8_encode(self.body) frames = [ - commands.Basic.Publish(exchange=exchange, - routing_key=routing_key or '', - mandatory=mandatory, - immediate=immediate), - header.ContentHeader(body_size=len(payload), - properties=self._properties) + commands.Basic.Publish( + exchange=exchange, + routing_key=routing_key or '', + mandatory=mandatory, + immediate=immediate, + ), + header.ContentHeader( + body_size=len(payload), properties=self._properties + ), ] # Calculate how many body frames are needed - pieces = math.ceil(len(payload) / float(self.channel.maximum_frame_size)) + pieces = math.ceil( + len(payload) / float(self.channel.maximum_frame_size) + ) # Send the message for offset in range(0, pieces): @@ -263,9 +279,11 @@ def reject(self, requeue: bool = False) -> None: """ if not self.method: raise exceptions.ActionException( - 'Can not reject non-received message') - basic_reject = commands.Basic.Reject(self.method.delivery_tag, - requeue=requeue) + 'Can not reject non-received message' + ) + basic_reject = commands.Basic.Reject( + self.method.delivery_tag, requeue=requeue + ) self.channel.write_frame(basic_reject) def _add_auto_message_id(self) -> None: @@ -277,8 +295,14 @@ def _add_timestamp(self) -> None: self.properties['timestamp'] = datetime.datetime.now(tz=datetime.UTC) @staticmethod - def _as_datetime(value: datetime.datetime | time.struct_time | int | float | str | bytes) \ - -> datetime.datetime | None: + def _as_datetime( + value: datetime.datetime + | time.struct_time + | int + | float + | str + | bytes, + ) -> datetime.datetime | None: """Return the passed in value as a ``datetime.datetime`` value. :param value: The value to convert or pass through @@ -301,10 +325,12 @@ def _as_datetime(value: datetime.datetime | time.struct_time | int | float | str return datetime.datetime.fromtimestamp(value, tz=datetime.UTC) raise TypeError( - f'Could not cast a {value} value to a datetime.datetime') + f'Could not cast a {value} value to a datetime.datetime' + ) - def _auto_serialize(self, value: str | bytes | memoryview | dict | list) \ - -> str | bytes: + def _auto_serialize( + self, value: str | bytes | memoryview | dict | list + ) -> str | bytes: """Automatically serialize the body as JSON if it is a dict or list. :param value: The message body passed into the constructor @@ -357,7 +383,8 @@ def _invalid_properties(self) -> list[str]: """ return [ - key for key in self.properties + key + for key in self.properties if key not in commands.Basic.Properties.attributes() ] diff --git a/rabbitpy/simple.py b/rabbitpy/simple.py index c55aaa6..1ed0c05 100644 --- a/rabbitpy/simple.py +++ b/rabbitpy/simple.py @@ -3,6 +3,7 @@ complex and less verbose for one-off or simple use cases. """ + import types import typing @@ -38,10 +39,12 @@ def __enter__(self) -> channel.Channel: self.channel = self.connection.channel() return self.channel - def __exit__(self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - unused_exc_tb: types.TracebackType | None): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ): if not self.channel.closed: self.channel.close() if not self.connection.closed: @@ -50,12 +53,13 @@ def __exit__(self, raise -def consume(uri: str | None = None, - queue_name: str | None = None, - no_ack: bool | None = False, - prefetch: int | None = None, - priority: int | None = None) \ - -> typing.Generator[message.Message, None, None]: +def consume( + uri: str | None = None, + queue_name: str | None = None, + no_ack: bool | None = False, + prefetch: int | None = None, + priority: int | None = None, +) -> typing.Generator[message.Message, None, None]: """Consume messages from the queue as a generator: .. code:: python @@ -77,9 +81,9 @@ def consume(uri: str | None = None, yield from queue.consume(no_ack, prefetch, priority) -def get(uri: str | None = None, - queue_name: str | None = None) \ - -> message.Message | None: +def get( + uri: str | None = None, queue_name: str | None = None +) -> message.Message | None: """Get a message from RabbitMQ, auto-acknowledging with RabbitMQ if one is returned. @@ -96,12 +100,14 @@ def get(uri: str | None = None, return queue.get(False) -def publish(uri: str | None = None, - exchange_name: str | None = None, - routing_key: str | None = None, - body: bytes | str | None = None, - properties: dict | None = None, - confirm: bool = False) -> bool | None: +def publish( + uri: str | None = None, + exchange_name: str | None = None, + routing_key: str | None = None, + body: bytes | str | None = None, + properties: dict | None = None, + confirm: bool = False, +) -> bool | None: """Publish a message to RabbitMQ. This should only be used for one-off publishing, as you will suffer a performance penalty if you use it repeatedly instead creating a connection and channel and publishing on that @@ -122,21 +128,24 @@ def publish(uri: str | None = None, if confirm: chan.enable_publisher_confirms() return msg.publish( - exchange_name, routing_key or '', mandatory=True) + exchange_name, routing_key or '', mandatory=True + ) else: msg.publish(exchange_name, routing_key or '') -def create_queue(uri: str | None = None, - queue_name: str = '', - durable: bool = True, - auto_delete: bool = False, - max_length: int | None = None, - message_ttl: int | None = None, - expires: int | None = None, - dead_letter_exchange: str | None = None, - dead_letter_routing_key: str | None = None, - arguments: dict | None = None): +def create_queue( + uri: str | None = None, + queue_name: str = '', + durable: bool = True, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None, +): """Create a queue with RabbitMQ. This should only be used for one-off operations. If a queue name is omitted, the name will be automatically generated by RabbitMQ. @@ -155,22 +164,24 @@ def create_queue(uri: str | None = None, :raises: :py:class:`rabbitpy.RemoteClosedException` """ - _validate_name(queue_name, 'queue') + if queue_name: + _validate_name(queue_name, 'queue') with SimpleChannel(uri) as c: - amqp_queue.Queue(c, - queue_name, - durable=durable, - auto_delete=auto_delete, - max_length=max_length, - message_ttl=message_ttl, - expires=expires, - dead_letter_exchange=dead_letter_exchange, - dead_letter_routing_key=dead_letter_routing_key, - arguments=arguments).declare() - - -def delete_queue(uri: str | None = None, - queue_name: str = '') -> None: + amqp_queue.Queue( + c, + queue_name, + durable=durable, + auto_delete=auto_delete, + max_length=max_length, + message_ttl=message_ttl, + expires=expires, + dead_letter_exchange=dead_letter_exchange, + dead_letter_routing_key=dead_letter_routing_key, + arguments=arguments, + ).declare() + + +def delete_queue(uri: str | None = None, queue_name: str = '') -> None: """Delete a queue from RabbitMQ. This should only be used for one-off operations. @@ -185,9 +196,11 @@ def delete_queue(uri: str | None = None, amqp_queue.Queue(c, queue_name).delete() -def create_direct_exchange(uri: str | None = None, - exchange_name: str | None = None, - durable: bool = True) -> None: +def create_direct_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a direct exchange with RabbitMQ. This should only be used for one-off operations. @@ -201,9 +214,11 @@ def create_direct_exchange(uri: str | None = None, _create_exchange(uri, exchange_name, exchange.DirectExchange, durable) -def create_fanout_exchange(uri: str | None = None, - exchange_name: str | None = None, - durable: bool = True) -> None: +def create_fanout_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a fanout exchange with RabbitMQ. This should only be used for one-off operations. @@ -217,9 +232,11 @@ def create_fanout_exchange(uri: str | None = None, _create_exchange(uri, exchange_name, exchange.FanoutExchange, durable) -def create_headers_exchange(uri: str | None = None, - exchange_name: str | None = None, - durable: bool = True) -> None: +def create_headers_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a headers exchange with RabbitMQ. This should only be used for one-off operations. @@ -233,9 +250,11 @@ def create_headers_exchange(uri: str | None = None, _create_exchange(uri, exchange_name, exchange.HeadersExchange, durable) -def create_topic_exchange(uri: str | None = None, - exchange_name: str | None = None, - durable: bool = True) -> None: +def create_topic_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. @@ -249,8 +268,9 @@ def create_topic_exchange(uri: str | None = None, _create_exchange(uri, exchange_name, exchange.TopicExchange, durable) -def delete_exchange(uri: str | None = None, - exchange_name: str | None = None) -> None: +def delete_exchange( + uri: str | None = None, exchange_name: str | None = None +) -> None: """Delete an exchange from RabbitMQ. This should only be used for one-off operations. @@ -265,10 +285,12 @@ def delete_exchange(uri: str | None = None, exchange.Exchange(c, exchange_name).delete() -def _create_exchange(uri: str | None, - exchange_name: str | None, - exchange_class: typing.Callable, - durable: bool) -> None: +def _create_exchange( + uri: str | None, + exchange_name: str | None, + exchange_class: typing.Callable, + durable: bool, +) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py index 274d864..571917d 100644 --- a/rabbitpy/tx.py +++ b/rabbitpy/tx.py @@ -3,6 +3,7 @@ and allows for any AMQP command to be issued, then committed or rolled back. """ + import logging import types @@ -43,10 +44,12 @@ def __enter__(self) -> 'Tx': self.select() return self - def __exit__(self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - unused_exc_tb: types.TracebackType | None): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ) -> bool | None: """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -54,12 +57,15 @@ def __exit__(self, if exc_type: LOGGER.warning('Exiting Transaction on exception: %r', exc_val) if self._selected: - self.rollback() - raise exc_val - else: - LOGGER.debug('Committing transaction on exit of context block') - if self._selected: - self.commit() + try: + self.rollback() + except exceptions.NoActiveTransactionError: + LOGGER.warning('Skipping rollback on a closed channel') + return False + LOGGER.debug('Committing transaction on exit of context block') + if self._selected: + self.commit() + return None def select(self) -> bool: """Select standard transaction mode @@ -88,8 +94,8 @@ def commit(self) -> bool: except exceptions.ChannelClosedException as error: LOGGER.warning('Error committing transaction: %s', error) raise exceptions.NoActiveTransactionError() from error - self._selected = False - return isinstance(response, commands.Tx.CommitOk) + self._selected = isinstance(response, commands.Tx.CommitOk) + return self._selected def rollback(self) -> bool: """Abandon the current transaction @@ -108,5 +114,5 @@ def rollback(self) -> bool: except exceptions.ChannelClosedException as error: LOGGER.warning('Error rolling back transaction: %s', error) raise exceptions.NoActiveTransactionError() from error - self._selected = False - return isinstance(response, commands.Tx.RollbackOk) + self._selected = isinstance(response, commands.Tx.RollbackOk) + return self._selected diff --git a/rabbitpy/utils.py b/rabbitpy/utils.py index 259d71d..0873117 100644 --- a/rabbitpy/utils.py +++ b/rabbitpy/utils.py @@ -3,6 +3,7 @@ ========= """ + import collections import logging import platform @@ -15,7 +16,8 @@ Parsed = collections.namedtuple( 'Parsed', - 'scheme,netloc,path,params,query,fragment,username,password,hostname,port') + 'scheme,netloc,path,params,query,fragment,username,password,hostname,port', +) def maybe_utf8_encode(value: str | bytes) -> bytes: @@ -38,10 +40,18 @@ def urlparse(url: str) -> Parsed: """ value = f'http{url[4:]}' if url[:4] == 'amqp' else url parsed = parse.urlparse(value) - return Parsed(parsed.scheme.replace('http', 'amqp'), parsed.netloc, - parsed.path, parsed.params, parsed.query, parsed.fragment, - parsed.username, parsed.password, parsed.hostname, - parsed.port) + return Parsed( + parsed.scheme.replace('http', 'amqp'), + parsed.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + parsed.username, + parsed.password, + parsed.hostname, + parsed.port, + ) def is_string(value: object) -> bool: diff --git a/tests/base_tests.py b/tests/base_tests.py index 27862e0..516b7ea 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -2,12 +2,12 @@ Test the rabbitpy.base classes """ + from rabbitpy import base, utils from tests import helpers class AMQPClassTests(helpers.TestCase): - def test_channel_valid(self): obj = base.AMQPClass(self.channel, 'Foo') self.assertEqual(obj.channel, self.channel) diff --git a/tests/channel_test.py b/tests/channel_test.py index 6372eee..d7bc348 100644 --- a/tests/channel_test.py +++ b/tests/channel_test.py @@ -2,12 +2,12 @@ Test the rabbitpy.channel classes """ + from rabbitpy import exceptions from tests import helpers class ServerCapabilitiesTest(helpers.TestCase): - def test_basic_nack_disabled(self): self.channel._server_capabilities['basic.nack'] = False self.assertFalse(self.channel._supports_basic_nack) @@ -50,15 +50,23 @@ def test_publisher_confirms_enabled(self): def test_invoking_consume_raises(self): self.channel._server_capabilities['consumer_priorities'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel._consume, self, True, 100) + self.assertRaises( + exceptions.NotSupportedError, + self.channel._consume, + self, + True, + 100, + ) def test_invoking_basic_nack_raises(self): self.channel._server_capabilities['basic_nack'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel._multi_nack, 100) + self.assertRaises( + exceptions.NotSupportedError, self.channel._multi_nack, 100 + ) def test_invoking_enable_publisher_confirms_raises(self): self.channel._server_capabilities['publisher_confirms'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel.enable_publisher_confirms) + self.assertRaises( + exceptions.NotSupportedError, + self.channel.enable_publisher_confirms, + ) diff --git a/tests/helpers.py b/tests/helpers.py index f152dad..b8caa26 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,11 +2,10 @@ import unittest from unittest import mock -from rabbitpy import channel, connection, events +from rabbitpy import channel, events class TestCase(unittest.TestCase): - def setUp(self): self.connection = mock.MagicMock('rabbitpy.connection.Connection') self.connection._io = mock.Mock() @@ -18,11 +17,15 @@ def setUp(self): self.connection._exceptions = queue.Queue() self.connection.open = True self.connection.closed = False - self.channel = channel.Channel(1, {}, - self.connection._events, - self.connection._exceptions, - queue.Queue(), - queue.Queue(), 32768, - self.connection._io.write_trigger, - connection=self.connection) + self.channel = channel.Channel( + 1, + {}, + self.connection._events, + self.connection._exceptions, + queue.Queue(), + queue.Queue(), + 32768, + self.connection._io.write_trigger, + connection=self.connection, + ) self.channel._set_state(self.channel.OPEN) diff --git a/tests/integration_tests.py b/tests/integration_tests.py index 618d41c..861beef 100644 --- a/tests/integration_tests.py +++ b/tests/integration_tests.py @@ -3,10 +3,9 @@ import re import threading import time - import unittest -from urllib import parse import uuid +from urllib import parse import rabbitpy from rabbitpy import exceptions, utils @@ -18,7 +17,6 @@ class ConfirmedPublishQueueLengthTest(unittest.TestCase): - ITERATIONS = 5 def setUp(self): @@ -47,7 +45,6 @@ def test_get_returns_expected_message(self): class PublishAndGetTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -61,12 +58,16 @@ def setUp(self): self.message_body = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.get') def tearDown(self): @@ -79,15 +80,17 @@ def test_get_returns_expected_message(self): msg = self.queue.get(True) self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) class PublishAndConsumeTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -101,12 +104,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -119,10 +126,13 @@ def test_get_returns_expected_message(self): for msg in self.queue.consume(no_ack=True, prefetch=1): self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) break if utils.PYPY: @@ -130,7 +140,6 @@ def test_get_returns_expected_message(self): class PublishAndConsumeIteratorTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -144,12 +153,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -162,10 +175,13 @@ def test_iterator_returns_expected_message(self): for msg in self.queue: self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) msg.ack() LOGGER.info('breaking out of iterator') @@ -176,7 +192,6 @@ def test_iterator_returns_expected_message(self): class PublishAndConsumeIteratorStopTest(unittest.TestCase): - PUBLISH_COUNT = 10 def setUp(self): @@ -193,14 +208,19 @@ def setUp(self): self.message_type = 'test' for iteration in range(0, self.PUBLISH_COUNT): - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) - self.msg.publish(self.exchange, - f'test.publish.consume {iteration}') + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) + self.msg.publish( + self.exchange, f'test.publish.consume {iteration}' + ) def stop_consumer(self): LOGGER.info('Stopping the consumer') @@ -232,7 +252,6 @@ def test_iterator_exits_on_stop(self): class ConsumerTagTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -246,12 +265,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -264,10 +287,13 @@ def test_iterator_returns_expected_message(self): for msg in self.queue.consume(consumer_tag='test-tag'): self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) msg.ack() LOGGER.info('breaking out of iterator') @@ -277,9 +303,7 @@ def test_iterator_returns_expected_message(self): self.assertFalse(self.queue.consuming) - class RedeliveredFlagTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -306,7 +330,6 @@ def test_redelivered_flag_is_set(self): class UnnamedQueueDeclareTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -324,7 +347,6 @@ def test_declaring_nameless_queue(self): class SimpleCreateQueueTests(unittest.TestCase): - def test_create_queue(self): name = 'simple-create-queue' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -337,11 +359,11 @@ def test_create_queue(self): class SimpleCreateDirectExchangeTests(unittest.TestCase): - def test_create(self): name = 'direct-exchange-name' rabbitpy.create_direct_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.DirectExchange(channel, name) @@ -353,11 +375,11 @@ def test_raises_on_empty_name(self): class SimpleCreateFanoutExchangeTests(unittest.TestCase): - def test_create(self): name = 'fanout-exchange-name' rabbitpy.create_fanout_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.FanoutExchange(channel, name) @@ -369,11 +391,11 @@ def test_raises_on_empty_name(self): class SimpleCreateHeadersExchangeTests(unittest.TestCase): - def test_create(self): name = 'headers-exchange-name' rabbitpy.create_headers_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.HeadersExchange(channel, name) @@ -385,11 +407,11 @@ def test_raises_on_empty_name(self): class SimpleCreateTopicExchangeTests(unittest.TestCase): - def test_create(self): name = 'topic-exchange-name' rabbitpy.create_topic_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.TopicExchange(channel, name) @@ -401,25 +423,24 @@ def test_raises_on_empty_name(self): class SimpleDeleteExchangeTests(unittest.TestCase): - def test_delete(self): name = 'delete-exchange-name' rabbitpy.create_topic_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) rabbitpy.delete_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.TopicExchange(channel, name) - self.assertRaises(exceptions.AMQPNotFound, - obj.declare, True) + self.assertRaises(exceptions.AMQPNotFound, obj.declare, True) def test_raises_on_empty_name(self): self.assertRaises(ValueError, rabbitpy.delete_exchange) class SimpleDeleteQueueTests(unittest.TestCase): - def test_delete(self): name = 'delete-queue-name' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -427,27 +448,28 @@ def test_delete(self): with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.Queue(channel, name) - self.assertRaises(exceptions.AMQPNotFound, - obj.declare, True) + self.assertRaises(exceptions.AMQPNotFound, obj.declare, True) def test_raises_on_empty_name(self): self.assertRaises(ValueError, rabbitpy.delete_queue) class SimpleGetTests(unittest.TestCase): - def test_get_empty(self): name = 'queue-name-get' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertIsNone( - rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name)) + rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) + ) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) def test_get_msg(self): body = b'test-body' name = 'queue-name-get' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, body=body) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], routing_key=name, body=body + ) result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) self.assertEqual(result.body, body) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -457,30 +479,39 @@ def test_raises_on_empty_name(self): class SimplePublishTests(unittest.TestCase): - def test_publish_with_confirm(self): body = b'test-body' name = 'simple-publish' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertTrue( - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, - body=body, confirm=True)) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], + routing_key=name, + body=body, + confirm=True, + ) + ) result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) self.assertEqual(result.body, body) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) class SimpleConsumeTests(unittest.TestCase): - def test_publish_with_confirm(self): body = b'test-body' name = 'simple-consume-tests' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertTrue( - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, - body=body, confirm=True)) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], + routing_key=name, + body=body, + confirm=True, + ) + ) for message in rabbitpy.consume( - os.environ['RABBITMQ_URL'], queue_name=name, no_ack=True): + os.environ['RABBITMQ_URL'], queue_name=name, no_ack=True + ): self.assertEqual(message.body, body) break rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -495,7 +526,6 @@ def test_raises_on_empty_name(self): class Issue119TestCase(unittest.TestCase): - def test_exception_is_raised(self): conn = rabbitpy.Connection(os.environ['RABBITMQ_URL']) channel = conn.channel() @@ -505,18 +535,18 @@ def test_exception_is_raised(self): msg.publish(exchange='invalid', mandatory=True) - class AccessDeniedDuringConnectionTests(unittest.TestCase): - def test_exception_is_raised(self): url_parts = parse.urlsplit(os.environ['RABBITMQ_URL']) netloc = f'{uuid.uuid4().hex}:{uuid.uuid4().hex}@{url_parts.hostname}:{url_parts.port or 5672}' - url = parse.urlunsplit(( - url_parts.scheme, - netloc, - url_parts.path, - url_parts.query, - url_parts.fragment, - )) + url = parse.urlunsplit( + ( + url_parts.scheme, + netloc, + url_parts.path, + url_parts.query, + url_parts.fragment, + ) + ) with self.assertRaises(exceptions.AMQPAccessRefused): rabbitpy.Connection(url) diff --git a/tests/test_amqp.py b/tests/test_amqp.py index 3119092..a1cd7f2 100644 --- a/tests/test_amqp.py +++ b/tests/test_amqp.py @@ -2,6 +2,7 @@ Test the rabbitpy.amqp class """ + from unittest import mock from rabbitpy import amqp @@ -9,7 +10,6 @@ class BasicAckTests(helpers.TestCase): - def test_basic_ack_invokes_write_frame(self): with mock.patch.object(self.channel, 'write_frame') as method: obj = amqp.AMQP(self.channel) diff --git a/tests/test_exchange.py b/tests/test_exchange.py index 7739539..eaf0eec 100644 --- a/tests/test_exchange.py +++ b/tests/test_exchange.py @@ -2,6 +2,7 @@ Test the rabbitpy.exchange classes """ + from unittest import mock from pamqp import commands as specification @@ -11,38 +12,41 @@ class TxTests(helpers.TestCase): - @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_declare(self, rpc): rpc.return_value = specification.Exchange.DeclareOk obj = exchange.Exchange(self.channel, 'foo') obj.declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Declare + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_delete(self, rpc): rpc.return_value = specification.Exchange.DeleteOk obj = exchange.Exchange(self.channel, 'foo') obj.delete() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Delete) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Delete + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_bind(self, rpc): rpc.return_value = specification.Exchange.BindOk obj = exchange.Exchange(self.channel, 'foo') obj.bind('a', 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Bind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_unbind(self, rpc): rpc.return_value = specification.Exchange.UnbindOk obj = exchange.Exchange(self.channel, 'foo') obj.unbind('a', 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Unbind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_bind_obj(self, rpc): @@ -51,8 +55,9 @@ def test_bind_sends_exchange_bind_obj(self, rpc): val = mock.Mock() val.name = 'bar' obj.bind(val, 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Bind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_unbind_obj(self, rpc): @@ -61,33 +66,30 @@ def test_bind_sends_exchange_unbind_obj(self, rpc): val = mock.Mock() val.name = 'bar' obj.unbind(val, 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Unbind + ) class DirectExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.DirectExchange(self.channel, 'direct-test') self.assertEqual(obj.type, 'direct') class FanoutExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.FanoutExchange(self.channel, 'fanout-test') self.assertEqual(obj.type, 'fanout') class HeadersExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.HeadersExchange(self.channel, 'headers-test') self.assertEqual(obj.type, 'headers') class TopicExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.TopicExchange(self.channel, 'topic-test') self.assertEqual(obj.type, 'topic') diff --git a/tests/test_message.py b/tests/test_message.py index 726ec4b..5aebc91 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -2,6 +2,7 @@ Test the rabbitpy.message.Message class """ + import datetime import json import logging @@ -19,7 +20,6 @@ class TestCreation(helpers.TestCase): - def setUp(self): super().setUp() self.body = uuid.uuid4() @@ -36,7 +36,6 @@ def test_message_message_id_property_set(self): class TestCreationWithDictBody(helpers.TestCase): - def setUp(self): super().setUp() self.body = {'foo': str(uuid.uuid4())} @@ -46,81 +45,88 @@ def test_message_body(self): self.assertEqual(self.msg.body, json.dumps(self.body)) def test_message_content_type_is_set(self): - self.assertEqual(self.msg.properties['content_type'], - 'application/json') + self.assertEqual( + self.msg.properties['content_type'], 'application/json' + ) class TestCreationWithStructTimeTimestamp(helpers.TestCase): - def setUp(self): super().setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': time.localtime()}) + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': time.localtime()} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithFloatTimestamp(helpers.TestCase): - def setUp(self): super().setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': time.time()}) + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': time.time()} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithIntTimestamp(helpers.TestCase): - def setUp(self): super().setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': int(time.time())}) + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': int(time.time())} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithInvalidTimestampType(helpers.TestCase): - def test_message_timestamp_property_is_datetime(self): - self.assertRaises(TypeError, - message.Message, - self.channel, - str(uuid.uuid4()), - {'timestamp': ['Ohai']}) + self.assertRaises( + TypeError, + message.Message, + self.channel, + str(uuid.uuid4()), + {'timestamp': ['Ohai']}, + ) class TestCreationWithNoneTimestamp(helpers.TestCase): - def setUp(self): super().setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': None}) + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': None} + ) def test_message_timestamp_property_is_datetime(self): self.assertIsNone(self.msg.properties['timestamp']) class TestCreationWithStrTimestamp(helpers.TestCase): - def setUp(self): super().setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': str(int(time.time()))}) + self.msg = message.Message( + self.channel, + str(uuid.uuid4()), + {'timestamp': str(int(time.time()))}, + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithDictBodyAndProperties(helpers.TestCase): - def setUp(self): super().setUp() self.body = {'foo': str(uuid.uuid4())} @@ -130,12 +136,12 @@ def test_message_body(self): self.assertEqual(self.msg.body, json.dumps(self.body)) def test_message_content_type_is_set(self): - self.assertEqual(self.msg.properties['content_type'], - 'application/json') + self.assertEqual( + self.msg.properties['content_type'], 'application/json' + ) class TestNonOpinionatedCreation(helpers.TestCase): - def setUp(self): super().setUp() self.body = str(uuid.uuid4()) @@ -152,23 +158,24 @@ def test_message_timestamp_property_is_not_set(self): class TestWithPropertiesCreation(helpers.TestCase): - def setUp(self): super().setUp() self.body = uuid.uuid4() - self.props = {'app_id': b'Foo', - 'content_type': b'application/json', - 'content_encoding': b'gzip', - 'correlation_id': str(uuid.uuid4()), - 'delivery_mode': 2, - 'expiration': int(time.time()) + 10, - 'headers': {'foo': 'bar'}, - 'message_id': str(uuid.uuid4()), - 'message_type': b'TestCreation', - 'priority': 9, - 'reply_to': b'none', - 'timestamp': datetime.datetime.now(tz=datetime.UTC), - 'user_id': b'guest'} + self.props = { + 'app_id': b'Foo', + 'content_type': b'application/json', + 'content_encoding': b'gzip', + 'correlation_id': str(uuid.uuid4()), + 'delivery_mode': 2, + 'expiration': int(time.time()) + 10, + 'headers': {'foo': 'bar'}, + 'message_id': str(uuid.uuid4()), + 'message_type': b'TestCreation', + 'priority': 9, + 'reply_to': b'none', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'user_id': b'guest', + } self.msg = message.Message(self.channel, self.body, dict(self.props)) def test_message_body(self): @@ -179,16 +186,17 @@ def test_message_properties_match(self): class TestInvalidPropertyHandling(helpers.TestCase): - def test_invalid_property_raises_key_error(self): - self.assertRaises(KeyError, - message.Message, - self.channel, - str(uuid.uuid4()), {'invalid': True}) + self.assertRaises( + KeyError, + message.Message, + self.channel, + str(uuid.uuid4()), + {'invalid': True}, + ) class TestDeliveredMessageObject(helpers.TestCase): - BODY = '{"foo": "bar", "val": 1}' PROPERTIES = {'message_type': 'test'} CONSUMER_TAG = 'ctag0' @@ -199,11 +207,13 @@ class TestDeliveredMessageObject(helpers.TestCase): def setUp(self): super().setUp() - self.method = specification.Basic.Deliver(self.CONSUMER_TAG, - self.DELIVERY_TAG, - self.REDELIVERED, - self.EXCHANGE, - self.ROUTING_KEY) + self.method = specification.Basic.Deliver( + self.CONSUMER_TAG, + self.DELIVERY_TAG, + self.REDELIVERED, + self.EXCHANGE, + self.ROUTING_KEY, + ) self.msg = message.Message(self.channel, self.BODY, self.PROPERTIES) self.msg.method = self.method self.msg.name = self.method.name @@ -238,8 +248,7 @@ def test_ack_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.ack() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_ack_channel_write_frame_multiple_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -268,8 +277,7 @@ def test_nack_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.nack() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_nack_channel_write_frame_requeue_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -310,8 +318,7 @@ def test_reject_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.reject() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_reject_channel_write_frame_requeue_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -327,10 +334,7 @@ def test_reject_channel_write_frame_requeue_true_value(self): class TestNonDeliveredMessageObject(helpers.TestCase): - - BODY = {'foo': str(uuid.uuid4()), - 'bar': 'baz', - 'qux': 1} + BODY = {'foo': str(uuid.uuid4()), 'bar': 'baz', 'qux': 1} def setUp(self): super().setUp() @@ -369,15 +373,13 @@ def test_coerce_property_str_to_empty_dict(self): def test_coerce_property_str_timestamp(self): self.msg.properties['timestamp'] = str(int(time.time())) self.msg._coerce_properties() - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestPublishing(helpers.TestCase): - - BODY = {'foo': str(uuid.uuid4()), - 'bar': 'baz', - 'qux': 1} + BODY = {'foo': str(uuid.uuid4()), 'bar': 'baz', 'qux': 1} EXCHANGE = 'foo' ROUTING_KEY = 'bar.baz' @@ -389,23 +391,29 @@ def setUp(self, write_frames): self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) def test_publish_invokes_write_frame_with_basic_publish(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][0], - specification.Basic.Publish) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][0], + specification.Basic.Publish, + ) def test_publish_with_exchange_object(self): _exchange = exchange.Exchange(self.channel, self.EXCHANGE) with mock.patch('rabbitpy.channel.Channel.write_frames') as wframes: self.msg.publish(_exchange, self.ROUTING_KEY) - self.assertEqual(wframes.mock_calls[0][1][0][0].exchange, - self.EXCHANGE) + self.assertEqual( + wframes.mock_calls[0][1][0][0].exchange, self.EXCHANGE + ) def test_publish_with_exchange_str(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][0].exchange, - self.EXCHANGE) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][0].exchange, self.EXCHANGE + ) def test_publish_routing_key_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][0].routing_key, - self.ROUTING_KEY) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][0].routing_key, + self.ROUTING_KEY, + ) def test_publish_mandatory_false_value(self): self.assertFalse(self.write_frames.mock_calls[0][1][0][0].mandatory) @@ -416,30 +424,34 @@ def test_publish_mandatory_true_value(self): self.assertTrue(wframes.mock_calls[0][1][0][0].mandatory) def test_publish_invokes_write_frame_with_content_header(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][1], - header.ContentHeader) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][1], header.ContentHeader + ) def test_content_header_frame_body_size(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][1].body_size, - len(self.msg.body)) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][1].body_size, + len(self.msg.body), + ) def test_content_header_frame_properties(self): value = self.write_frames.mock_calls[0][1][0][1].properties for key in self.msg.properties: - self.assertEqual(self.msg.properties[key], - getattr(value, key)) + self.assertEqual(self.msg.properties[key], getattr(value, key)) def test_publish_invokes_write_frame_with_body(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][2], - body.ContentBody) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][2], body.ContentBody + ) def test_content_body_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, - bytes(json.dumps(self.BODY).encode('utf-8'))) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][2].value, + bytes(json.dumps(self.BODY).encode('utf-8')), + ) class TestJSONDeserialization(helpers.TestCase): - BODY = b'{"qux": 1, "foo": "d5525b9d", "bar": "baz"}' def setUp(self): @@ -452,7 +464,6 @@ def test_json_body(self): class TestPublishingUnicode(helpers.TestCase): - try: BODY = '☢'.decode('utf-8') except AttributeError: @@ -468,12 +479,13 @@ def setUp(self, write_frames): self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) def test_content_body_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, - self.BODY.encode('utf-8')) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][2].value, + self.BODY.encode('utf-8'), + ) class TestPublisherConfirms(helpers.TestCase): - BODY = 'confirm-this' EXCHANGE = 'foo' ROUTING_KEY = 'bar.baz' @@ -496,5 +508,9 @@ def test_confirm_nack_response_returns_false(self): def test_confirm_other_raises(self): self._confirm_wait.return_value = specification.Basic.Consume() - self.assertRaises(exceptions.UnexpectedResponseError, - self.msg.publish, self.EXCHANGE, self.ROUTING_KEY) + self.assertRaises( + exceptions.UnexpectedResponseError, + self.msg.publish, + self.EXCHANGE, + self.ROUTING_KEY, + ) diff --git a/tests/test_queue.py b/tests/test_queue.py index bc5a0fc..bd2d247 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,6 +2,7 @@ Test the rabbitpy.amqp_queue classes """ + from unittest import mock from pamqp import commands as specification @@ -11,7 +12,6 @@ class QueueInitializationTests(helpers.TestCase): - def test_empty_queue_name(self): queue = amqp_queue.Queue(self.channel) self.assertEqual(queue.name, '') @@ -32,8 +32,9 @@ def test_auto_delete_false(self): self.assertFalse(queue.auto_delete) def test_auto_delete_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, None, None, 10) + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, None, None, 10 + ) def test_durable_default(self): queue = amqp_queue.Queue(self.channel) @@ -48,8 +49,9 @@ def test_durable_false(self): self.assertFalse(queue.durable) def test_durable_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, 'Foo') + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, 'Foo' + ) def test_exclusive_default(self): queue = amqp_queue.Queue(self.channel) @@ -64,8 +66,9 @@ def test_exclusive_false(self): self.assertFalse(queue.exclusive) def test_exclusive_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, None, 'Bar') + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, None, 'Bar' + ) def test_expires_default(self): queue = amqp_queue.Queue(self.channel) @@ -77,14 +80,25 @@ def test_expires_named_value(self): self.assertIsInstance(queue.expires, int) def test_expires_positional_value(self): - queue = amqp_queue.Queue(self.channel, '', True, False, True, - None, None, 10) + queue = amqp_queue.Queue( + self.channel, '', True, False, True, None, None, 10 + ) self.assertEqual(queue.expires, 10) self.assertIsInstance(queue.expires, int) def test_expires_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, None, None, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + 'Foo', + ) def test_max_length_default(self): queue = amqp_queue.Queue(self.channel) @@ -101,8 +115,16 @@ def test_max_length_positional_value(self): self.assertIsInstance(queue.max_length, int) def test_max_length_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + 'Foo', + ) def test_message_ttl_default(self): queue = amqp_queue.Queue(self.channel) @@ -119,8 +141,17 @@ def test_message_ttl_positional_value(self): self.assertIsInstance(queue.message_ttl, int) def test_message_ttl_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, None, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + 'Foo', + ) def test_dlx_default(self): queue = amqp_queue.Queue(self.channel) @@ -131,7 +162,9 @@ def test_dlx_value(self): self.assertEqual(queue.dead_letter_exchange, 'dlx-name') def test_dlx_bytes(self): - queue = amqp_queue.Queue(self.channel, dead_letter_exchange=b'dlx-name') + queue = amqp_queue.Queue( + self.channel, dead_letter_exchange=b'dlx-name' + ) self.assertIsInstance(queue.dead_letter_exchange, bytes) def test_dlx_str(self): @@ -140,109 +173,154 @@ def test_dlx_str(self): @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') def test_dlx_py2_unicode(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_exchange='dlx-name') + queue = amqp_queue.Queue(self.channel, dead_letter_exchange='dlx-name') self.assertIsInstance(queue.dead_letter_exchange, str) def test_message_dlx_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, - False, True, None, None, None, True) + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + None, + True, + ) def test_dlr_default(self): queue = amqp_queue.Queue(self.channel) self.assertIsNone(queue.dead_letter_routing_key) def test_dlr_value(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key='routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key='routing-key' + ) self.assertEqual(queue.dead_letter_routing_key, 'routing-key') def test_dlr_bytes(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key=b'routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key=b'routing-key' + ) self.assertIsInstance(queue.dead_letter_routing_key, bytes) def test_dlr_str(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key='routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key='routing-key' + ) self.assertIsInstance(queue.dead_letter_routing_key, str) @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') def test_dlr_py2_unicode(self): routing_key = 'routing-key' - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key=routing_key) + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key=routing_key + ) self.assertIsInstance(queue.dead_letter_routing_key, str) def test_dlr_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, - False, True, None, None, None, None, True) + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + None, + None, + True, + ) @helpers.unittest.skipIf( - utils.PYPY, 'PyPy bails this due to improper __exit__ behavior') + utils.PYPY, 'PyPy bails this due to improper __exit__ behavior' + ) def test_stop_consuming_raises_exception(self): queue = amqp_queue.Queue(self.channel) self.assertRaises(exceptions.NotConsumingError, queue.stop_consuming) class QueueDeclareTests(helpers.TestCase): - def test_default_declare(self): obj = amqp_queue.Queue(self.channel) - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': False, - 'queue': '', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': '', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) def test_default_declare_passive(self): obj = amqp_queue.Queue(self.channel) - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': True, - 'queue': '', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': True, + 'queue': '', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(True)), expectation) def test_queue_name(self): obj = amqp_queue.Queue(self.channel, 'my-queue') - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': False, - 'queue': 'my-queue', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) def test_non_defaults(self): - obj = amqp_queue.Queue(self.channel, 'my-queue', False, True, True, - 100, 30000, 60000, 'dlx-name', 'dlrk') - expectation = {'arguments': {'x-expires': 60000, - 'x-max-length': 100, - 'x-message-ttl': 30000, - 'x-dead-letter-exchange': 'dlx-name', - 'x-dead-letter-routing-key': 'dlrk'}, - 'auto_delete': True, - 'durable': False, - 'exclusive': True, - 'nowait': False, - 'passive': False, - 'queue': 'my-queue', - 'ticket': 0} + obj = amqp_queue.Queue( + self.channel, + 'my-queue', + False, + True, + True, + 100, + 30000, + 60000, + 'dlx-name', + 'dlrk', + ) + expectation = { + 'arguments': { + 'x-expires': 60000, + 'x-max-length': 100, + 'x-message-ttl': 30000, + 'x-dead-letter-exchange': 'dlx-name', + 'x-dead-letter-routing-key': 'dlrk', + }, + 'auto_delete': True, + 'durable': False, + 'exclusive': True, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) class QueueAssignmentTests(helpers.TestCase): - def setUp(self): super().setUp() self.queue = amqp_queue.Queue(self.channel) @@ -258,6 +336,7 @@ def test_auto_delete_assign_false(self): def test_auto_delete_assign_raises_type_error(self): def assign_value(): self.queue.auto_delete = 'Hello' + self.assertRaises(ValueError, assign_value) def test_durable_assign_true(self): @@ -271,6 +350,7 @@ def test_durable_assign_false(self): def test_durable_assign_raises_type_error(self): def assign_value(): self.queue.durable = 'Hello' + self.assertRaises(ValueError, assign_value) def test_exclusive_assign_true(self): @@ -284,6 +364,7 @@ def test_exclusive_assign_false(self): def test_exclusive_assign_raises_type_error(self): def assign_value(): self.queue.exclusive = 'Hello' + self.assertRaises(ValueError, assign_value) def test_expires_assign_value(self): @@ -293,6 +374,7 @@ def test_expires_assign_value(self): def test_expires_assign_raises_type_error(self): def assign_value(): self.queue.expires = 'Hello' + self.assertRaises(ValueError, assign_value) def test_max_length_assign_value(self): @@ -302,6 +384,7 @@ def test_max_length_assign_value(self): def test_max_length_assign_raises_type_error(self): def assign_value(): self.queue.max_length = 'Hello' + self.assertRaises(ValueError, assign_value) def test_message_ttl_assign_value(self): @@ -311,6 +394,7 @@ def test_message_ttl_assign_value(self): def test_message_ttl_assign_raises_type_error(self): def assign_value(): self.queue.message_ttl = 'Hello' + self.assertRaises(ValueError, assign_value) def test_dead_letter_exchange_assign_value(self): @@ -320,6 +404,7 @@ def test_dead_letter_exchange_assign_value(self): def test_dead_letter_exchange_assign_raises_type_error(self): def assign_value(): self.queue.dead_letter_exchange = 1234 + self.assertRaises(ValueError, assign_value) def test_dead_letter_routing_key_assign_value(self): @@ -329,6 +414,7 @@ def test_dead_letter_routing_key_assign_value(self): def test_dead_letter_routing_key_assign_raises_type_error(self): def assign_value(): self.queue.dead_letter_routing_key = 1234 + self.assertRaises(ValueError, assign_value) def test_arguments_assign_value(self): @@ -338,6 +424,7 @@ def test_arguments_assign_value(self): def test_arguments_assign_raises_type_error(self): def assign_value(): self.queue.arguments = 1234 + self.assertRaises(ValueError, assign_value) @@ -354,35 +441,46 @@ class WriteFrameTests(helpers.TestCase): def setUp(self): super().setUp() - self.queue = amqp_queue.Queue(self.channel, self.NAME, self.DURABLE, - self.EXCLUSIVE, self.AUTO_DELETE, - self.MAX_LENGTH, self.MESSAGE_TTL, - self.EXPIRES, self.DEAD_LETTER_EXCHANGE, - self.DEAD_LETTER_ROUTING_KEY) + self.queue = amqp_queue.Queue( + self.channel, + self.NAME, + self.DURABLE, + self.EXCLUSIVE, + self.AUTO_DELETE, + self.MAX_LENGTH, + self.MESSAGE_TTL, + self.EXPIRES, + self.DEAD_LETTER_EXCHANGE, + self.DEAD_LETTER_ROUTING_KEY, + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_declare_invokes_write_frame_with_queue_declare(self, rpc): self.queue.declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_invokes_write_frame_with_queue_declare(self, rpc): self.queue.ha_declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_list_invokes_write_frame_with_queue_declare(self, rpc): self.queue.ha_declare(['foo', 'bar']) - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_list_sets_proper_attributes(self, rpc): self.queue.ha_declare(['foo', 'bar']) - self.assertListEqual(self.queue.arguments['x-ha-nodes'], - ['foo', 'bar']) + self.assertListEqual( + self.queue.arguments['x-ha-nodes'], ['foo', 'bar'] + ) self.assertEqual(self.queue.arguments['x-ha-policy'], 'nodes') @mock.patch('rabbitpy.amqp_queue.Queue._rpc') @@ -395,32 +493,36 @@ def test_ha_declare_clears_ha_nodes(self, rpc): @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_bind_invokes_write_frame_with_queue_bind(self, rpc): self.queue.bind('foo', 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Bind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_invokes_write_frame_with_queue_declare(self, rpc): self.queue.unbind('foo', 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Unbind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_with_obj_invokes_write_frame_with_queue_declare(self, rpc): exchange = mock.Mock() exchange.name = 'foo' self.queue.unbind(exchange, 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Unbind) - + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Unbind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_invokes_write_frame_with_queue_delete(self, rpc): self.queue.delete() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Delete) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Delete + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_purge_invokes_write_frame_with_queue_purge(self, rpc): self.queue.purge() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Purge) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Purge + ) diff --git a/tests/test_tx.py b/tests/test_tx.py index 5a05c5a..f57a052 100644 --- a/tests/test_tx.py +++ b/tests/test_tx.py @@ -2,6 +2,7 @@ Test the rabbitpy.tx classes """ + from unittest import mock from pamqp import commands as specification @@ -11,7 +12,6 @@ class TxTests(helpers.TestCase): - def test_obj_creation_does_not_invoke_select(self): with mock.patch('rabbitpy.tx.Tx.select') as select: transaction = tx.Tx(self.channel) @@ -50,8 +50,7 @@ def test_select_invokes_rpc_with_tx_select(self, rpc): rpc.return_value = specification.Tx.CommitOk with tx.Tx(self.channel): pass - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Tx.Select) + self.assertIsInstance(rpc.mock_calls[0][1][0], specification.Tx.Select) @mock.patch('rabbitpy.tx.Tx._rpc') def test_commit_invokes_rpc_with_tx_commit(self, rpc): @@ -60,16 +59,14 @@ def test_commit_invokes_rpc_with_tx_commit(self, rpc): obj.select() rpc.return_value = specification.Tx.CommitOk obj.commit() - self.assertIsInstance(rpc.mock_calls[1][1][0], - specification.Tx.Commit) + self.assertIsInstance(rpc.mock_calls[1][1][0], specification.Tx.Commit) @mock.patch('rabbitpy.tx.Tx._rpc') def test_commit_raises_when_channel_closed(self, rpc): obj = tx.Tx(self.channel) obj.select() rpc.side_effect = exceptions.ChannelClosedException - self.assertRaises(exceptions.NoActiveTransactionError, - obj.commit) + self.assertRaises(exceptions.NoActiveTransactionError, obj.commit) @mock.patch('rabbitpy.tx.Tx._rpc') def test_rollback_invokes_rpc_with_tx_rollback(self, rpc): @@ -78,13 +75,13 @@ def test_rollback_invokes_rpc_with_tx_rollback(self, rpc): obj.select() rpc.return_value = specification.Tx.RollbackOk obj.rollback() - self.assertIsInstance(rpc.mock_calls[1][1][0], - specification.Tx.Rollback) + self.assertIsInstance( + rpc.mock_calls[1][1][0], specification.Tx.Rollback + ) @mock.patch('rabbitpy.tx.Tx._rpc') def test_rollback_raises_when_channel_closed(self, rpc): obj = tx.Tx(self.channel) obj.select() rpc.side_effect = exceptions.ChannelClosedException - self.assertRaises(exceptions.NoActiveTransactionError, - obj.rollback) + self.assertRaises(exceptions.NoActiveTransactionError, obj.rollback) diff --git a/tests/utils_tests.py b/tests/utils_tests.py index bd8a6f5..3c13efd 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -2,8 +2,9 @@ Test the rabbitpy utils module """ -import unittest + import sys +import unittest from rabbitpy import utils @@ -12,7 +13,6 @@ class UtilsTestCase(unittest.TestCase): - AMQP = 'amqp://guest:guest@localhost:5672/%2F?heartbeat_interval=1' AMQPS = 'amqps://guest:guest@localhost:5672/%2F?heartbeat_interval=1' @@ -44,8 +44,9 @@ def test_urlparse_fragment(self): self.assertEqual(utils.urlparse(self.AMQPS).fragment, self.FRAGMENT) def test_parse_qs(self): - self.assertDictEqual(utils.parse_qs(self.QUERY), - {'heartbeat_interval': ['1']}) + self.assertDictEqual( + utils.parse_qs(self.QUERY), {'heartbeat_interval': ['1']} + ) def test_is_string_str(self): self.assertTrue(utils.is_string('Foo')) From 141a3822a62e36b1be2c32a01dace7bb89eee82e Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 09:58:13 -0400 Subject: [PATCH 3/8] Fix NameError on Python 3.11-3.13 in base.py TYPE_CHECKING-only imports (chan, conn) were used in class-body annotations evaluated eagerly on Python <3.14. Adding from __future__ import annotations defers annotation evaluation to strings on all supported versions, matching the behavior Python 3.14 provides by default. Co-Authored-By: Gavin M. Roy --- rabbitpy/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rabbitpy/base.py b/rabbitpy/base.py index 114fba2..6191139 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -3,6 +3,8 @@ """ +from __future__ import annotations + import logging import queue import socket From 0f8c0007b5394480e2727c74544ce894fd948251 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 10:03:34 -0400 Subject: [PATCH 4/8] Fix circular import causing AttributeError on Python 3.11-3.13 exchange.py, amqp_queue.py, message.py, and tx.py all imported rabbitpy.channel at module level, but channel.py imports those same modules, creating a cycle. On Python 3.11-3.13 this caused an AttributeError at class definition time because the partially-initialized channel module had no Channel attribute yet. Fix by adding `from __future__ import annotations` (defers all annotation evaluation to runtime) and moving the channel import under `if typing.TYPE_CHECKING:`, matching the pattern already used in base.py. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rabbitpy/amqp_queue.py | 6 +++++- rabbitpy/exchange.py | 6 +++++- rabbitpy/message.py | 8 ++++++-- rabbitpy/tx.py | 9 +++++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index 855b247..b713588 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -27,13 +27,17 @@ """ +from __future__ import annotations + import logging import typing from pamqp import commands from rabbitpy import base, exceptions, exchange, message, utils -from rabbitpy import channel as chan + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan LOGGER = logging.getLogger(__name__) diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index d9df8a4..ff9e5ce 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -9,13 +9,17 @@ """ +from __future__ import annotations + import logging import typing from pamqp import commands from rabbitpy import base -from rabbitpy import channel as chan + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan LOGGER = logging.getLogger(__name__) diff --git a/rabbitpy/message.py b/rabbitpy/message.py index ca48d49..725ef9f 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -6,6 +6,8 @@ """ +from __future__ import annotations + import datetime import json import logging @@ -17,8 +19,10 @@ from pamqp import body, commands, header from rabbitpy import base, exceptions, utils -from rabbitpy import channel as chan -from rabbitpy import exchange as exc + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan + from rabbitpy import exchange as exc LOGGER = logging.getLogger(__name__) diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py index 571917d..f3cd55a 100644 --- a/rabbitpy/tx.py +++ b/rabbitpy/tx.py @@ -4,13 +4,18 @@ """ +from __future__ import annotations + import logging import types +import typing from pamqp import commands from rabbitpy import base, exceptions -from rabbitpy import channel as chan + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan LOGGER = logging.getLogger(__name__) @@ -36,7 +41,7 @@ def __init__(self, channel: chan.Channel): super().__init__(channel, 'Tx') self._selected = False - def __enter__(self) -> 'Tx': + def __enter__(self) -> Tx: """For use as a context manager, return a handle to this object instance. From 7f8b61e217ff84fe678b64d2b7646e7e35fe8ad2 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 10:07:15 -0400 Subject: [PATCH 5/8] Fix circular import in channel.py on Python < 3.14 channel.py imported amqp and amqp_queue at module level for use in type annotations only, creating a circular import cycle: __init__ -> amqp -> channel -> amqp (circular). Python 3.14 evaluates annotations lazily by default, so the cycle was harmless there, but 3.11/3.12/3.13 evaluate class-body annotations at import time, causing AttributeError on the partially-initialised module. Fix follows the same pattern applied to exchange.py, amqp_queue.py, message.py, and tx.py: add `from __future__ import annotations` to defer all annotation evaluation, and move the amqp/amqp_queue imports under TYPE_CHECKING so they are invisible at runtime. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rabbitpy/channel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index 58cc29b..4c9cca0 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -6,17 +6,18 @@ """ +from __future__ import annotations + import logging import queue import socket import types +from typing import TYPE_CHECKING from pamqp import base as pamqp_base from pamqp import commands, header from rabbitpy import ( - amqp, - amqp_queue, base, exceptions, message, @@ -28,6 +29,9 @@ events as rabbitpy_events, ) +if TYPE_CHECKING: + from rabbitpy import amqp, amqp_queue + LOGGER = logging.getLogger(__name__) BASIC_DELIVER = 'Basic.Deliver' @@ -99,7 +103,7 @@ def __init__( self._server_capabilities = server_capabilities self.consumers = {} - def __enter__(self) -> 'Channel': + def __enter__(self) -> Channel: """For use as a context manager, return a handle to this object instance. From d514694dafdaeb212ced948738700145a8b80835 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 10:11:45 -0400 Subject: [PATCH 6/8] Fix race condition in IO tests causing Python 3.12-only failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three MockServerConnectedTestCase tests checked _buffer and bytes_received immediately after write_protocol_header() (which only sleeps 100 ms) — before the IO thread had necessarily finished processing the server's response. On Python 3.12 CI runners, asyncio event-loop scheduling latency routinely exceeds 100 ms (300-400 ms observed in logs), so the assertions fired before the IO thread had received or dispatched any data. Fix: move all internal-state assertions (_buffer, bytes_received) to after the blocking queue.get() calls that are already present in each test. Those calls serve as natural synchronisation barriers: they only return once the IO thread has unmarshalled and enqueued the frame, guaranteeing that _buffer and bytes_received are in their final state. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tests/test_io.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/test_io.py b/tests/test_io.py index 1c38845..7e87b02 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -327,14 +327,17 @@ async def test_on_read_ready(self): await self.write_protocol_header() - self.assertEqual(self.io._buffer, b'') - self.assertEqual(self.io.bytes_received, 335) - + # Block until the IO thread has dispatched the frame; this guarantees + # that _buffer and bytes_received reflect the fully-processed state + # regardless of event-loop scheduling delays (notably slow on 3.12). frame = self.io._channels[0].get(True, 3) self.assertIsInstance(frame, commands.Connection.Start) assert isinstance(frame, commands.Connection.Start) self.assertEqual(frame.locales, 'en_US') + self.assertEqual(self.io._buffer, b'') + self.assertEqual(self.io.bytes_received, 335) + async def test_on_data_received_multiple_frames(self): mock_server = await self.get_mock_server() mock_server.set_expectation(b'AMQP\x00\x00\t\x01') @@ -356,8 +359,8 @@ async def test_on_data_received_multiple_frames(self): await self.write_protocol_header() - self.assertEqual(self.io.bytes_received, 347) - + # Block on both frames before checking bytes_received so that the IO + # thread has fully processed the response before we inspect state. frame = self.io._channels[0].get(True, 3) self.assertIsInstance(frame, commands.Connection.Start) assert isinstance(frame, commands.Connection.Start) @@ -366,6 +369,8 @@ async def test_on_data_received_multiple_frames(self): frame = self.io._channels[1].get(True, 3) self.assertIsInstance(frame, commands.Tx.RollbackOk) + self.assertEqual(self.io.bytes_received, 347) + async def test_on_data_received_remaining_buffer(self): mock_server = await self.get_mock_server() mock_server.set_expectation(b'AMQP\x00\x00\t\x01') @@ -375,11 +380,14 @@ async def test_on_data_received_remaining_buffer(self): await self.write_protocol_header() - self.assertEqual(self.io._buffer, b'\x01\x00\x00') - self.assertEqual(self.io.bytes_received, 12) + # Block until the IO thread has dispatched the frame before checking + # _buffer and bytes_received to avoid a race on slow event loops. frame = self.read_queue.get(True, 3) self.assertIsInstance(frame, commands.Tx.RollbackOk) + self.assertEqual(self.io._buffer, b'\x01\x00\x00') + self.assertEqual(self.io.bytes_received, 12) + async def test_remote_name(self): mock_server = await self.get_mock_server() assert mock_server._transport is not None From d5a6c193eb3f2bfae3e613fb42f500cf24929e84 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 10:18:12 -0400 Subject: [PATCH 7/8] Address Python 3.11-specific ERROR in test_write_ready_socket_oserror On Python 3.11 the asyncio writer callback registered on the IO thread's event loop fires more eagerly than on later versions. _on_write_ready is called both directly by the test and concurrently by the writer callback, enqueuing a second ConnectionException into self.exceptions. tearDown treats any remaining exception as an uncaught error and re-raises it. Fix by exiting the mock.patch context before yielding, then draining any duplicate ConnectionExceptions that the IO thread may have added before the write buffer was emptied. Each drained exception is asserted to be a ConnectionException so the drain itself is a test rather than silent suppression. Co-Authored-By: Gavin M. Roy --- tests/test_io.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_io.py b/tests/test_io.py index 7e87b02..a494790 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -448,6 +448,15 @@ async def test_write_ready_socket_oserror(self): self.assertExceptionAdded(exceptions.ConnectionException) + # The mock context has exited; sendall no longer raises. Yield to let + # the IO thread's writer callback (which fires continuously on Python + # 3.11) settle, then drain any duplicate ConnectionExceptions that may + # have been added before the write buffer was emptied above. + await asyncio.sleep(0.1) + while not self.exceptions.empty(): + dup = self.exceptions.get_nowait() + self.assertIsInstance(dup, exceptions.ConnectionException) + async def test_write_ready_socket_errno_35(self): with mock.patch('socket.socket.sendall') as mock_sendall: self.io.start() From 245f20049e0766ab388d8981c3f35d33a4d87726 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 10:23:30 -0400 Subject: [PATCH 8/8] Address 10 CodeRabbit review issues across 5 files - __init__.py: remove unimported module names (amqp_queue, channel, connection, exceptions, exchange, message, simple, tx) from __all__ to prevent AttributeError on access - amqp_queue.py: pass queue=self.name to Queue.Purge() so the correct queue is purged instead of the AMQP default empty-string queue - base.py: guard __int__ against None _channel_id with ValueError; update rpc() and _wait_on_frame() return types to include None to match their implicit return paths - exchange.py: remove mutable class-level arguments={} default that would be shared across instances; fix Exchange.__init__ arguments annotation from bool|None to dict|None; fix HeadersExchange docstring typo ("direct" -> "headers") - message.py: fix json() return type annotation from bytes to Any; guard maximum_frame_size=0 in publish() with fallback to avoid ZeroDivisionError Co-Authored-By: Gavin M. Roy --- rabbitpy/__init__.py | 8 -------- rabbitpy/amqp_queue.py | 2 +- rabbitpy/base.py | 5 ++++- rabbitpy/exchange.py | 5 ++--- rabbitpy/message.py | 11 +++++------ 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/rabbitpy/__init__.py b/rabbitpy/__init__.py index cd8f701..b9ebd55 100644 --- a/rabbitpy/__init__.py +++ b/rabbitpy/__init__.py @@ -50,9 +50,6 @@ 'TopicExchange', 'Tx', '__version__', - 'amqp_queue', - 'channel', - 'connection', 'consume', 'create_direct_exchange', 'create_fanout_exchange', @@ -61,11 +58,6 @@ 'create_topic_exchange', 'delete_exchange', 'delete_queue', - 'exceptions', - 'exchange', 'get', - 'message', 'publish', - 'simple', - 'tx', ] diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index b713588..8be2b0d 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -311,7 +311,7 @@ def ha_declare(self, nodes: list[str] | None = None) -> tuple[int, int]: def purge(self) -> None: """Purge the queue of all of its messages.""" - self._rpc(commands.Queue.Purge()) + self._rpc(commands.Queue.Purge(queue=self.name)) def stop_consuming(self) -> None: """Stop consuming messages. This is usually invoked if you want to diff --git a/rabbitpy/base.py b/rabbitpy/base.py index 6191139..47e1ca7 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -193,6 +193,8 @@ def __init__( def __int__(self) -> int: """Return the numeric channel ID""" + if self._channel_id is None: + raise ValueError('Channel ID is not yet assigned') return self._channel_id def close(self) -> None: @@ -234,7 +236,7 @@ def close(self) -> None: def rpc( self, frame_value: base.Frame - ) -> base.Frame | commands.Queue.DeclareOk: + ) -> base.Frame | commands.Queue.DeclareOk | None: """Send an RPC command to the remote server. This should not be directly invoked. @@ -448,6 +450,7 @@ def _wait_on_frame( | commands.Queue.DeclareOk | header.ContentHeader | body.ContentBody + | None ): """Read from the queue, blocking until a result is returned. An individual frame type or a list of frame types can be passed in to wait diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index ff9e5ce..e097bfb 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -47,7 +47,6 @@ class _Exchange(base.AMQPClass): """ durable = False - arguments = {} auto_delete = False type = 'direct' @@ -149,7 +148,7 @@ def __init__( exchange_type: str = 'direct', durable: bool = False, auto_delete: bool = False, - arguments: bool | None = None, + arguments: dict | None = None, ): """Create a new instance of the exchange object.""" self.type = exchange_type @@ -187,7 +186,7 @@ class FanoutExchange(_Exchange): class HeadersExchange(_Exchange): - """The HeadersExchange class is used for interacting with direct exchanges + """The HeadersExchange class is used for interacting with headers exchanges only. :param channel: The channel object to communicate on diff --git a/rabbitpy/message.py b/rabbitpy/message.py index 725ef9f..1a414d6 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -172,7 +172,7 @@ def ack(self, all_previous: bool = False) -> None: ) self.channel.write_frame(basic_ack) - def json(self) -> bytes: + def json(self) -> typing.Any: """Deserialize the message body if it is JSON, returning the value.""" try: return json.loads(self.body) @@ -248,14 +248,13 @@ def publish( ] # Calculate how many body frames are needed - pieces = math.ceil( - len(payload) / float(self.channel.maximum_frame_size) - ) + frame_size = self.channel.maximum_frame_size or len(payload) or 1 + pieces = math.ceil(len(payload) / float(frame_size)) # Send the message for offset in range(0, pieces): - start = self.channel.maximum_frame_size * offset - end = start + self.channel.maximum_frame_size + start = frame_size * offset + end = start + frame_size if end > len(payload): end = len(payload) frames.append(body.ContentBody(payload[start:end]))