From 06355da27f00be451eb6956e64431ac507826313 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 12:43:18 -0400 Subject: [PATCH 01/59] Python3 Only Updates - Add Type Annotations - Remove Python2 Support - Fix some "private" methods and attributes that are called across modules --- rabbitpy/base.py | 316 ++++++++++++++++++++--------------------------- 1 file changed, 137 insertions(+), 179 deletions(-) diff --git a/rabbitpy/base.py b/rabbitpy/base.py index cd1f18d..4c71778 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -3,35 +3,46 @@ """ import logging +import queue +import socket import threading +import typing -from pamqp import specification - -from rabbitpy import exceptions -from rabbitpy import utils -from rabbitpy.utils import queue +from pamqp import base, commands +from rabbitpy import (connection as conn, channel as chan, exceptions, message, + utils) LOGGER = logging.getLogger(__name__) +Interrupt = typing.TypedDict( + 'Interrupt', { + 'event': threading.Event, + 'callback': typing.Optional[typing.Callable], + 'args': typing.Optional[typing.Iterable[typing.Any]] + }) + +FrameTypes = typing.Union[str, typing.Type[base.Frame], + typing.List[typing.Union[str, + typing.Type[base.Frame]]]] -class ChannelWriter(object): # pylint: disable=too-few-public-methods +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 use - :type channel: rabbitpy.channel.Channel + :param channel: The channel to write to """ - def __init__(self, channel): + + def __init__(self, channel: chan.Channel): self.channel = channel - def _rpc(self, frame_value): + def _rpc(self, frame_value: base.Frame) \ + -> typing.Union[base.Frame, message.Message]: """Execute the RPC command for the frame. - :param pamqp.specification.Frame frame_value: The frame to send - :rtype: pamqp.specification.Frame or pamqp.message.Message + :param frame_value: The frame to send """ LOGGER.debug('Issuing RPC to RabbitMQ: %r', frame_value) @@ -39,10 +50,10 @@ def _rpc(self, frame_value): raise exceptions.ChannelClosedException() return self.channel.rpc(frame_value) - def _write_frame(self, frame_value): + def _write_frame(self, frame_value: base.Frame) -> None: """Write a frame to the channel's connection - :param pamqp.specification.Frame frame_value: The frame to send + :param frame_value: The frame to send """ self.channel.write_frame(frame_value) @@ -50,143 +61,117 @@ def _write_frame(self, frame_value): class AMQPClass(ChannelWriter): # pylint: disable=too-few-public-methods """Base Class object AMQP object classes""" - def __init__(self, channel, name): + + def __init__(self, channel: chan.Channel, name: str): """Create a new ClassObject. :param channel: The channel to execute commands on - :type channel: rabbitpy.Channel - :param str name: Set the name + :param name: Set the name :raises: ValueError """ super(AMQPClass, self).__init__(channel) - # Use type so there's not a circular dependency - if channel.__class__.__name__ != 'Channel': + if not isinstance(channel, chan.Channel): raise ValueError('channel must be a valid rabbitpy Channel object') - elif not utils.is_string(name): - raise ValueError('name must be str, bytes or unicode') self.name = name -class StatefulObject(object): +class StatefulObject(utils.DebuggingOptimizationMixin): """Base object for rabbitpy classes that need to maintain state such as connection and channel. """ - CLOSED = 0x00 - CLOSING = 0x01 - OPEN = 0x02 - OPENING = 0x03 + CLOSED = 0 + CLOSING = 1 + OPEN = 2 + OPENING = 3 - STATES = {0x00: 'Closed', - 0x01: 'Closing', - 0x02: 'Open', - 0x03: 'Opening'} + STATES = {0: 'Closed', 1: 'Closing', 2: 'Open', 3: 'Opening'} def __init__(self): """Create a new instance of the object defaulting to a closed state.""" - self._state = self.CLOSED + super(StatefulObject, self).__init__() + self._state: int = self.CLOSED - def _set_state(self, value): + def _set_state(self, value: int) -> None: """Set the state to the specified value, validating it is a supported state value. - :param int value: The new state value - :raises: ValueError + :param value: The new state value + """ - if value not in list(self.STATES.keys()): + if value not in self.STATES.keys(): raise ValueError('Invalid state value: %r' % value) - LOGGER.debug('%s setting state to %r', - self.__class__.__name__, self.STATES[value]) + if self._is_debugging: + LOGGER.debug('%s setting state to %r', self.__class__.__name__, + self.STATES[value]) self._state = value @property - def closed(self): - """Returns True if in the CLOSED runtime state - - :rtype: bool - - """ + def closed(self) -> bool: + """Returns True if in the CLOSED runtime state""" return self._state == self.CLOSED @property - def closing(self): - """Returns True if in the CLOSING runtime state - - :rtype: bool - - """ + def closing(self) -> bool: + """Returns True if in the CLOSING runtime state""" return self._state == self.CLOSING @property - def open(self): - """Returns True if in the OPEN runtime state - - :rtype: bool - - """ + def open(self) -> bool: + """Returns True if in the OPEN runtime state""" return self._state == self.OPEN @property - def opening(self): - """Returns True if in the OPENING runtime state - - :rtype: bool - - """ + def opening(self) -> bool: + """Returns True if in the OPENING runtime state""" return self._state == self.OPENING @property - def state(self): - """Return the runtime state value - - :rtype: int - - """ + def state(self) -> int: + """Return the runtime state value""" return self._state @property - def state_description(self): - """Returns the text based description of the runtime state - - :rtype: str - - """ + 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 = specification.Channel.Close DEFAULT_CLOSE_CODE = 200 DEFAULT_CLOSE_REASON = 'Normal Shutdown' REMOTE_CLOSED = 0x04 - def __init__(self, exception_queue, write_trigger, connection, - blocking_read=False): + def __init__(self, + exception_queue: queue.Queue, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False): super(AMQPChannel, self).__init__() if blocking_read: LOGGER.debug('Initialized with blocking read') - self.blocking_read = blocking_read - self._debugging = None - self._interrupt = {'event': threading.Event(), - 'callback': None, - 'args': None} - self._channel_id = None - self._connection = connection - self._exceptions = exception_queue - self._state = self.CLOSED - self._read_queue = None - self._waiting = False + self.blocking_read: bool = blocking_read + self._interrupt: Interrupt = { + 'event': threading.Event(), + 'callback': None, + 'args': None + } + self._channel_id: typing.Optional[int] = None + self._connection: conn.Connection = connection + self._exceptions: queue.Queue = exception_queue + self._read_queue: typing.Optional[queue.Queue] = None + self._waiting: bool = False self._write_lock = threading.Lock() - self._write_queue = None - self._write_trigger = write_trigger + self._write_queue: typing.Optional[queue.Queue] = None + self._write_trigger: socket.socket = write_trigger - def __int__(self): + def __int__(self) -> int: + """Return the numeric channel ID""" return self._channel_id - def close(self): + def close(self) -> None: """Close the AMQP channel""" if self._connection.closed: LOGGER.debug('Connection is closed, bailing') @@ -197,8 +182,8 @@ def close(self): 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() @@ -211,16 +196,15 @@ def close(self): LOGGER.debug('Channel %i Waiting for a valid response for %s', self._channel_id, frame_value.name) self.rpc(frame_value) - self._set_state(self.CLOSED) if self._is_debugging: LOGGER.debug('Channel #%i closed', self._channel_id) + self._set_state(self.CLOSED) - def rpc(self, frame_value): - """Send a RPC command to the remote server. This should not be directly - invoked. + def rpc(self, frame_value: base.Frame) -> typing.Union[base.Frame]: + """Send an RPC command to the remote server. This should not be + directly invoked. - :param pamqp.specification.Frame frame_value: The frame to send - :rtype: pamqp.specification.Frame or None + :param frame_value: The frame to send """ if self.closed: @@ -231,35 +215,32 @@ def rpc(self, frame_value): if frame_value.synchronous: return self._wait_on_frame(frame_value.valid_responses) - def wait_for_confirmation(self): - """Used by the Message.publish method when publisher confirmations are - enabled. - - :rtype: pamqp.frame.Frame + def wait_for_confirmation(self) -> base.Frame: + """Used by the `Message.publish` method when publisher confirmations + are enabled. """ - return self._wait_on_frame([specification.Basic.Ack, - specification.Basic.Nack]) + return self._wait_on_frame([commands.Basic.Ack, commands.Basic.Nack]) - def write_frame(self, frame): + def write_frame(self, value: base.Frame) -> 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 pamqp.specification.Frame frame: The frame to write + :param value: The frame to write """ if self._can_write(): if self._is_debugging: - LOGGER.debug('Writing frame: %s', frame.name) + LOGGER.debug('Writing frame: %s', value.name) with self._write_lock: - self._write_queue.put((self._channel_id, frame)) + self._write_queue.put((self._channel_id, value)) self._trigger_write() - def write_frames(self, frames): + def write_frames(self, frames: typing.List[base.Frame]) -> None: """Add a list of frames for the IOWriter object to write to the socket when it can. - :param list frames: The list of frame to write + :param frames: The list of frame to write """ if self._can_write(): @@ -267,21 +248,16 @@ def write_frames(self, frames): LOGGER.debug('Writing frames: %r', [frame.name for frame in frames]) with self._write_lock: - # pylint: disable=expression-not-assigned - [self._write_queue.put((self._channel_id, frame)) - for frame in frames] + for frame in frames: + self._write_queue.put((self._channel_id, frame)) self._trigger_write() - def _build_close_frame(self): - """Return the proper close frame for this object. - - :rtype: pamqp.specification.Channel.Close + def _build_close_frame(self) -> commands.Channel.Close: + """Return the proper close frame for this object.""" + return commands.Channel.Close(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): + def _can_write(self) -> bool: self._check_for_exceptions() if self._connection.closed: raise exceptions.ConnectionClosed() @@ -289,20 +265,7 @@ def _can_write(self): raise exceptions.ChannelClosedException() return True - @property - def _is_debugging(self): - """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 - - def _check_for_exceptions(self): + def _check_for_exceptions(self) -> None: """Check if there are any queued exceptions to raise, raising it if there is. @@ -312,35 +275,38 @@ def _check_for_exceptions(self): self._exceptions.task_done() raise exception - def _check_for_pending_frames(self): + def _check_for_pending_frames(self) -> None: value = self._read_from_queue() if value: self._check_for_rpc_request(value) - LOGGER.debug('Read frame while shutting down: %r', value) + if self._is_debugging: + LOGGER.debug('Read frame while shutting down: %r', value) - def _check_for_rpc_request(self, 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, specification.Channel.Close): - LOGGER.debug('Channel closed') + if isinstance(value, commands.Channel.Close): + if self._is_debugging: + LOGGER.debug('Channel closed') self._on_remote_close(value) - def _force_close(self): + def _force_close(self) -> None: """Force the channel to mark itself as closed""" self._set_state(self.CLOSED) - LOGGER.debug('Channel #%i closed', self._channel_id) + if self._is_debugging: + LOGGER.debug('Channel #%i closed', self._channel_id) - def _interrupt_wait_on_frame(self, callback, *args): + 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 - :type callback: typing.Callable - :param list args: Args to pass to the callback + :param args: Args to pass to the callback """ self._check_for_exceptions() @@ -354,41 +320,35 @@ def _interrupt_wait_on_frame(self, callback, *args): self._interrupt['event'].set() @property - def _interrupt_is_set(self): + def _interrupt_is_set(self) -> bool: return self._interrupt['event'].is_set() - def _on_interrupt_set(self): - # pylint: disable=not-an-iterable,not-callable + 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): + def _on_remote_close(self, value: commands.Channel.Close) -> None: """Handle RabbitMQ remotely closing the channel :param value: The Channel.Close method frame - :type value: pamqp.spec.Channel.Close :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) + 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): - """Check to see if a frame is in the queue and if so, return it + raise exceptions.RemoteClosedChannelException( + self._channel_id, value.reply_code, value.reply_text) - :rtype: amqp.specification.Frame or None - - """ + def _read_from_queue(self) -> typing.Union[base.Frame, 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') @@ -402,22 +362,23 @@ def _read_from_queue(self): value = None return value - def _trigger_write(self): + def _trigger_write(self) -> None: """Notifies the IO loop we need to write a frame by writing a byte to a local socket. """ - utils.trigger_write(self._write_trigger) + try: + self._write_trigger.send(b'0') + except socket.error: + pass - def _validate_frame_type(self, frame_value, frame_type): + 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 - :type frame_value: pamqp.specification.Frame :param frame_type: The frame(s) to check against - :type frame_type: pamqp.specification.Frame or list - :rtype: bool """ if frame_value is None: @@ -433,20 +394,18 @@ def _validate_frame_type(self, frame_value, frame_type): if result: return True return False - elif isinstance(frame_value, specification.Frame): + elif isinstance(frame_value, commands.Frame): return frame_value.name == frame_type.name return False - def _wait_on_frame(self, frame_type=None): + def _wait_on_frame(self, frame_type: FrameTypes) -> base.Frame: """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 or list of names of the frame type(s) - :type frame_type: str|list|pamqp.specification.Frame - :rtype: Frame + :param frame_type: The name, frame type, list of names or frame types """ self._check_for_exceptions() @@ -456,9 +415,8 @@ def _wait_on_frame(self, frame_type=None): 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) From 4ebe9f9e26cdf4ea5df891e508c774dd0639c3ac Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 12:43:43 -0400 Subject: [PATCH 02/59] Bump versions Use the latest pamqp and bump the rabbitpy version --- pyproject.toml | 4 ++-- rabbitpy/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7ca5712..4e8c3b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rabbitpy" -version = "2.0.1" +version = "3.0.0" description = "A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library" readme = "README.rst" requires-python = ">=3.8" @@ -24,7 +24,7 @@ classifiers = [ "Topic :: Software Development :: Libraries" ] dependencies = [ - "pamqp>=2.3.0,<3.0", + "pamqp>=3.0,<4.0", ] [project.optional-dependencies] diff --git a/rabbitpy/__init__.py b/rabbitpy/__init__.py index 9ff243d..d3a6942 100644 --- a/rabbitpy/__init__.py +++ b/rabbitpy/__init__.py @@ -2,7 +2,7 @@ rabbitpy, a pythonic RabbitMQ client """ -__version__ = '2.0.1' +__version__ = '3.0.0' import logging From a78c21cb61144df317a5f7ce714a82154c81e753 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 12:47:12 -0400 Subject: [PATCH 03/59] Add type annotations --- rabbitpy/events.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/rabbitpy/events.py b/rabbitpy/events.py index ea0b59b..ac44491 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -4,6 +4,7 @@ """ import logging import threading +import typing LOGGER = logging.getLogger(__name__) @@ -28,12 +29,12 @@ 0x09: 'Socket Connected'} -def description(event_id): +def description(event_id: int) -> str: """Return the text description for an event""" return DESCRIPTIONS.get(event_id, event_id) -class Events(object): +class Events: """All events that get triggered in rabbitpy are funneled through this object for a common structure and method for raising and checking for them. @@ -43,13 +44,11 @@ def __init__(self): self._events = self._create_event_objects() @staticmethod - def _create_event_objects(): + def _create_event_objects() -> typing.Dict[int, threading.Event]: """Events are used like signals across threads for communicating state changes, used by the various threaded objects to communicate with each other when an action needs to be taken. - :rtype: dict - """ events = dict() for event in [CHANNEL0_CLOSE, @@ -64,12 +63,11 @@ def _create_event_objects(): events[event] = threading.Event() return events - def clear(self, event_id): + def clear(self, event_id: int) -> typing.Union[bool, None]: """Clear a set event, returning bool indicating success and None for an invalid event. - :param int event_id: The event to set - :rtype: bool + :param event_id: The event to clear """ if event_id not in self._events: @@ -83,12 +81,11 @@ def clear(self, event_id): self._events[event_id].clear() return True - def is_set(self, event_id): + def is_set(self, event_id: int) -> typing.Union[bool, None]: """Check if an event is triggered. Returns bool indicating state of the event being set. If the event is invalid, a None is returned instead. - :param int event_id: The event to fire - :rtype: bool + :param event_id: The event to fire """ if event_id not in self._events: @@ -96,12 +93,11 @@ def is_set(self, event_id): return None return self._events[event_id].is_set() - def set(self, event_id): + def set(self, event_id: int) -> typing.Union[bool, None]: """Trigger an event to fire. Returns bool indicating success in firing the event. If the event is not valid, return None. - :param int event_id: The event to fire - :rtype: bool + :param event_id: The event to fire """ if event_id not in self._events: @@ -115,14 +111,15 @@ def set(self, event_id): self._events[event_id].set() return True - def wait(self, event_id, timeout=1): + def wait(self, event_id: int, timeout: float = 1) \ + -> typing.Union[bool, None]: """Wait for an event to be set for up to `timeout` seconds. If `timeout` is None, block until the event is set. If the event is invalid, None will be returned, otherwise False is used to indicate the event is still not set when using a timeout. - :param int event_id: The event to wait for - :param float timeout: The number of seconds to wait + :param event_id: The event to wait for + :param timeout: The number of seconds to wait """ if event_id not in self._events: From 9f090f362ef1756f558d78658202b75271c48706 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 13:07:41 -0400 Subject: [PATCH 04/59] More Python3 Type Annotations and Cleanup --- rabbitpy/base.py | 7 +- rabbitpy/channel.py | 368 ++++++++++++++++++++------------------------ 2 files changed, 170 insertions(+), 205 deletions(-) diff --git a/rabbitpy/base.py b/rabbitpy/base.py index 4c71778..57d4a9a 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -8,7 +8,7 @@ import threading import typing -from pamqp import base, commands +from pamqp import base, body, commands, header from rabbitpy import (connection as conn, channel as chan, exceptions, message, utils) @@ -398,7 +398,10 @@ 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: + def _wait_on_frame(self, frame_type: FrameTypes) \ + -> typing.Union[base.Frame, + 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 diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index 9bc6174..ad51436 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -6,14 +6,15 @@ """ import logging +import queue +import socket +import types +import typing -from pamqp import specification as spec -from pamqp import PYTHON3 +from pamqp import base as pamqp_base, body as pamqp_body, commands, header -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import message -from rabbitpy.utils import queue +from rabbitpy import (amqp, amqp_queue, base, connection as conn, events as + rabbitpy_events, exceptions, message) LOGGER = logging.getLogger(__name__) @@ -45,16 +46,12 @@ class Channel(base.AMQPChannel): :param int channel_id: The channel # to use for this instance :param dict server_capabilities: Features the server supports :param events: Event management object - :type events: rabbitpy.Events :param exception_queue: Exception queue - :type exception_queue: queue.Queue - :param read_queue: Queue to read pending frames from - :type read_queue: queue.Queue - :param write_queue: Queue to write pending AMQP objs to - :type write_queue: queue.Queue - :param int maximum_frame_size: The max frame size for msg bodies - :param socket write_trigger: Write to this socket to break IO waiting - :param bool blocking_read: Use blocking Queue.get to improve performance + :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 @@ -62,15 +59,21 @@ class Channel(base.AMQPChannel): STATES = base.AMQPChannel.STATES STATES[0x04] = 'Remotely Closed' - def __init__(self, channel_id, server_capabilities, events, - exception_queue, read_queue, write_queue, - maximum_frame_size, write_trigger, connection, - blocking_read=False): + 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(Channel, self).__init__(exception_queue, write_trigger, connection, blocking_read) self._channel_id = channel_id - self._consumers = {} self._consuming = False self._events = events self._maximum_frame_size = maximum_frame_size @@ -78,17 +81,18 @@ def __init__(self, channel_id, server_capabilities, events, self._read_queue = read_queue self._write_queue = write_queue self._server_capabilities = server_capabilities + self.consumers = {} - def __enter__(self): + def __enter__(self) -> 'Channel': """For use as a context manager, return a handle to this object instance. - :rtype: Channel - """ return self - def __exit__(self, exc_type, exc_val, unused_exc_tb): + def __exit__(self, exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + unused_exc_tb: typing.Optional[types.TracebackType]): """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -100,7 +104,24 @@ def __exit__(self, exc_type, exc_val, unused_exc_tb): if self.open: self.close() - def close(self): + def cancel_consumer(self, + obj: typing.Union[amqp.AMQP, amqp_queue.Queue], + consumer_tag: typing.Optional[str] = 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. @@ -116,12 +137,12 @@ def close(self): self._set_state(self.CLOSING) # Empty the queue and nack the max id (and all previous) - if self._consumers: + 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) + 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) @@ -133,8 +154,8 @@ def close(self): 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 (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 @@ -143,147 +164,134 @@ def close(self): super(Channel, self).close() - def enable_publisher_confirms(self): + def consume_message(self) -> typing.Union[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. + `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(spec.Confirm.Select()) + self.rpc(commands.Confirm.Select()) self._publisher_confirms = True @property - def id(self): # pylint: disable=invalid-name - """Return the channel id - - :rtype: int - - """ + def id(self) -> int: # pylint: disable=invalid-name + """Return the channel id""" return self._channel_id @property - def maximum_frame_size(self): - """Return the AMQP maximum frame size - - :rtype: int - - """ + def maximum_frame_size(self) -> int: + """Return the AMQP maximum frame size""" return self._maximum_frame_size - def open(self): - """Open the channel, invoked directly upon creation by the Connection - - """ + 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(spec.Channel.OpenOk) + 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, all_channels=False): + 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 int value: The prefetch count to set - :param bool all_channels: Set the prefetch count on 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(spec.Basic.Qos(prefetch_count=value, global_=all_channels)) + self.rpc(commands.Basic.Qos(prefetch_count=value, + global_=all_channels)) - def prefetch_size(self, value, all_channels=False): + 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 to set + :param int value: The prefetch size value :param bool all_channels: Set the prefetch size on all channels on the same connection """ - if value is None: - return - self.rpc(spec.Basic.Qos(prefetch_size=value, global_=all_channels)) + if value: + self.rpc( + commands.Basic.Qos(prefetch_size=value, global_=all_channels)) @property - def publisher_confirms(self): - """Returns True if publisher confirms are enabled. - - :rtype: bool - - """ + def publisher_confirms(self) -> bool: + """Returns True if publisher confirms are enabled.""" return self._publisher_confirms - def recover(self, requeue=False): + def recover(self, requeue: bool = False) -> None: """Recover all unacknowledged messages that are associated with this channel. - :param bool requeue: Requeue the message + :param requeue: Requeue the message """ - self.rpc(spec.Basic.Recover(requeue=requeue)) + self.rpc(commands.Basic.Recover(requeue=requeue)) @staticmethod - def _build_open_frame(): - """Build and return a channel open frame - - :rtype: pamqp.spec.Channel.Open - - """ - return spec.Channel.Open() - - def _cancel_consumer(self, obj, consumer_tag=None, nowait=False): - """Cancel the consuming of a queue. - - :param rabbitpy.amqp_queue.Queue obj: The queue to cancel + def _build_open_frame() -> commands.Channel.Open: + """Build and return a channel open frame""" + return commands.Channel.Open() - """ - consumer_tag = consumer_tag or obj.consumer_tag - self._interrupt_wait_on_frame(self._on_ready_to_cancel, - consumer_tag, nowait) - - def _on_ready_to_cancel(self, consumer_tag, nowait): + 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 consumer_tag in self.consumers: + del self.consumers[consumer_tag] if nowait: - self.write_frame(spec.Basic.Cancel(consumer_tag=consumer_tag, - nowait=True)) + self.write_frame( + commands.Basic.Cancel(consumer_tag=consumer_tag, nowait=True)) return - self.rpc(spec.Basic.Cancel(consumer_tag=consumer_tag)) - - def _check_for_rpc_request(self, value): - """Inspect a frame to see if it's a RPC request from RabbitMQ. - - :param spec.Frame value: + 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 a RPC request from RabbitMQ.""" LOGGER.debug('Checking for RPC request: %r', value) super(Channel, self)._check_for_rpc_request(value) - if isinstance(value, spec.Basic.Return): + if isinstance(value, commands.Basic.Return): raise exceptions.MessageReturnedException(value.reply_code, value.reply_text, value.exchange, value.routing_key) - elif isinstance(value, spec.Basic.Cancel): + elif isinstance(value, commands.Basic.Cancel): self._waiting = False - if value.consumer_tag in self._consumers: - del self._consumers[value.consumer_tag] + if value.consumer_tag in self.consumers: + del self.consumers[value.consumer_tag] raise exceptions.RemoteCancellationException(value.consumer_tag) - def _consume(self, obj, no_ack, priority=None): + def _consume(self, + obj: amqp_queue.Queue, + no_ack: bool, + priority: typing.Optional[int] = None) -> None: """Register a Queue object as a consumer, issuing Basic.Consume. :param obj: The queue to consume - :type obj: rabbitpy.amqp_queue.Queue - :param bool no_ack: no_ack mode - :param int priority: Consumer priority + :param no_ack: no_ack mode + :param priority: Consumer priority :raises: ValueError """ @@ -294,40 +302,25 @@ def _consume(self, obj, no_ack, priority=None): if not isinstance(priority, int): raise ValueError('Consumer priority must be an int') args['x-priority'] = priority - self.rpc(spec.Basic.Consume(queue=obj.name, - consumer_tag=obj.consumer_tag, - no_ack=no_ack, - arguments=args)) - self._consumers[obj.consumer_tag] = (obj, no_ack) - - def _consume_message(self): - """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. - - :rtype: rabbitpy.message.Message - - """ - if not self._consumers: - raise exceptions.NotConsumingError - frame_value = self._wait_on_frame([spec.Basic.Deliver]) - LOGGER.debug('Waited on frame, got %r', frame_value) - if frame_value: - return self._wait_for_content_frames(frame_value) - return None - - def _create_message(self, method_frame, header_frame, body): + 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) + + def _create_message(self, + method_frame: typing.Optional[pamqp_base.Frame], + header_frame: typing.Optional[header.ContentHeader], + body: typing.Optional[typing.Union[str, bytes]]) \ + -> typing.Union[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 - :type method_frame: pamqp.specification.Frame :param header_frame: Header frame value - :type header_frame: pamqp.header.ContentHeader or None :param body: The message body - :type body: str or None - :rtype: rabbitpy.message.Message or None """ if not method_frame: @@ -341,12 +334,10 @@ def _create_message(self, method_frame, header_frame, body): msg.name = method_frame.name return msg - def _get_from_read_queue(self): + def _get_from_read_queue(self) -> typing.Union[pamqp_base.Frame, None]: """Fetch a frame from the read queue and return it, otherwise return None - :rtype: pamqp.specification.Frame - """ try: frame_value = self._read_queue.get(False) @@ -359,104 +350,87 @@ def _get_from_read_queue(self): pass return frame_value - def _get_message(self): - """Try and get a delivered message from the connection's message stack. - - :rtype: rabbitpy.message.Message or None - - """ - frame_value = self._wait_on_frame([spec.Basic.GetOk, - spec.Basic.GetEmpty]) - if isinstance(frame_value, spec.Basic.GetEmpty): + def _get_message(self) -> typing.Union[message.Message, None]: + """Try and get a delivered message from the connection's stack.""" + 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, requeue=True): + def _multi_nack(self, delivery_tag: int, requeue: bool = True) -> None: """Send a multiple negative acknowledgement, re-queueing the items - :param int delivery_tag: The delivery tag for this channel - :param bool requeue: Requeue the messages + :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(spec.Basic.Nack(delivery_tag=delivery_tag, - multiple=True, - requeue=requeue)) + self.rpc( + commands.Basic.Nack(delivery_tag=delivery_tag, + multiple=True, + requeue=requeue)) - def _reject_inbound_message(self, method_frame): + 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 pamqp.specification.Basic.Deliver method_frame: The method frame + :param method_frame: The method frame """ - self.rpc(spec.Basic.Reject(delivery_tag=method_frame.delivery_tag, - requeue=True)) + self.rpc( + commands.Basic.Reject(delivery_tag=method_frame.delivery_tag, + requeue=True)) @property - def _supports_basic_nack(self): - """Indicates if the server supports Basic.Nack - - :rtype: bool - - """ + 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): # pylint: disable=invalid-name + def _supports_consumer_cancel_notify(self) -> bool: """Indicates if the server supports sending consumer cancellation notifications - :rtype: bool - """ return self._server_capabilities.get('consumer_cancel_notify', False) @property - def _supports_consumer_priorities(self): - """Indicates if the server supports consumer priorities - - :rtype: bool - - """ + 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): - """Indicates if the server supports per consumer qos - - :rtype: bool - - """ + 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): - """Indicates if the server supports publisher confirmations - - :rtype: bool - - """ + 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): + def _wait_for_content_frames( + self, method_frame: typing.Union[commands.Basic.Deliver, + commands.Basic.Get, + commands.Basic.Return]) \ + -> typing.Union[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 - :type method_frame: Basic.Deliver or Basic.Get or Basic.Return - :rtype: rabbitpy.Message """ if self.closing or self.closed: return None - consuming = isinstance(method_frame, spec.Basic.Deliver) - if consuming and not self._consumers: + consuming = isinstance(method_frame, commands.Basic.Deliver) + if consuming and not self.consumers: return None if self._is_debugging: @@ -474,13 +448,7 @@ def _wait_for_content_frames(self, method_frame): error = False - # To retrieve the message body we must concatenate the binary content - # of several frames. The recommended idiom for this differs - # in py3 and py2. - if PYTHON3: - body_value = bytearray() - else: - body_chunks = [] + body_value = bytearray() body_length_received = 0 body_total_size = header_value.body_size @@ -495,19 +463,13 @@ def _wait_for_content_frames(self, method_frame): break elif self.closing or self.closed: error = True - elif consuming and not self._consumers: + elif consuming and not self.consumers: self._reject_inbound_message(method_frame) error = True if error: return body_length_received += len(body_part.value) - if PYTHON3: - body_value += body_part.value - else: - body_chunks.append(body_part.value) - - if not PYTHON3: - body_value = ''.join(body_chunks) + body_value += body_part.value return self._create_message(method_frame, header_value, body_value) From ccadd76292d9220615fd791c1c7c6326e10d5540 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 13:51:38 -0400 Subject: [PATCH 05/59] More Python3 / Type Annotation Updates --- rabbitpy/amqp.py | 391 +++++++++++++++++++++++------------------ rabbitpy/amqp_queue.py | 295 +++++++++++++------------------ rabbitpy/base.py | 24 ++- rabbitpy/channel.py | 68 +++---- rabbitpy/channel0.py | 180 +++++++++---------- 5 files changed, 484 insertions(+), 474 deletions(-) diff --git a/rabbitpy/amqp.py b/rabbitpy/amqp.py index dde40e9..7138963 100644 --- a/rabbitpy/amqp.py +++ b/rabbitpy/amqp.py @@ -2,12 +2,11 @@ AMQP Adapter """ -from pamqp import specification as spec +import typing -from rabbitpy import base -from rabbitpy import message -from rabbitpy import exceptions -from rabbitpy import utils +from pamqp import commands + +from rabbitpy import base, channel, message, exceptions, utils # pylint: disable=too-many-public-methods @@ -15,15 +14,16 @@ 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 rabbitmq.channel.Channel channel: The channel to use + :param rabbitpy_channel: The channel to use """ - def __init__(self, channel): - super(AMQP, self).__init__(channel) + + def __init__(self, rabbitpy_channel: channel.Channel): + super(AMQP, self).__init__(rabbitpy_channel) self.consumer_tag = 'rabbitpy.%s.%s' % (self.channel.id, id(self)) self._consuming = False - def basic_ack(self, delivery_tag=0, multiple=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 @@ -31,22 +31,27 @@ def basic_ack(self, delivery_tag=0, multiple=False): set of messages up to and including a specific message. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool multiple: Acknowledge multiple messages + :param multiple: Acknowledge multiple messages """ - self._write_frame(spec.Basic.Ack(delivery_tag, multiple)) - - def basic_consume(self, queue='', consumer_tag='', no_local=False, - no_ack=False, exclusive=False, nowait=False, - arguments=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: typing.Optional[dict] = 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 an generator, returning messages as they are + This method will act as a generator, returning messages as they are delivered from the server. Example use: @@ -54,29 +59,28 @@ def basic_consume(self, queue='', consumer_tag='', no_local=False, .. code:: python for message in basic_consume(queue_name): - print message.body + print(message.body) message.ack() - :param str queue: The queue name to consume from - :param str consumer_tag: The consumer tag - :param bool no_local: Do not deliver own messages - :param bool no_ack: No acknowledgement needed - :param bool exclusive: Request exclusive access - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :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 - # pylint: disable=protected-access - self.channel._consumers[consumer_tag] = (self, no_ack) - self._rpc(spec.Basic.Consume(0, queue, consumer_tag, no_local, no_ack, - exclusive, nowait, arguments)) + 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: - # pylint: disable=protected-access - msg = self.channel._consume_message() + msg = self.channel.consume_message() if msg: yield msg else: @@ -87,41 +91,45 @@ def basic_consume(self, queue='', consumer_tag='', no_local=False, if self._consuming: self.basic_cancel(consumer_tag) - def basic_cancel(self, consumer_tag='', nowait=False): + 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. + messages in between sending the cancel method and receiving the + cancel-ok reply. - :param str consumer_tag: Consumer tag - :param bool nowait: Do not send a reply method + :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() - # pylint: disable=protected-access - self.channel._cancel_consumer(self, consumer_tag, nowait) + self.channel.cancel_consumer(self, consumer_tag, nowait) self._consuming = False - def basic_get(self, queue='', no_ack=False): + def basic_get(self, queue: str = '', no_ack: bool = False) -> None: """Direct access to a queue - This method provides a direct access to the messages in a queue using a + 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 str queue: The queue name - :param bool no_ack: No acknowledgement needed + :param queue: The queue name + :param no_ack: No acknowledgement needed """ - self._rpc(spec.Basic.Get(0, queue, no_ack)) + self._rpc(commands.Basic.Get(0, queue, no_ack)) - def basic_nack(self, delivery_tag=0, multiple=False, requeue=True): + 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 @@ -132,15 +140,20 @@ def basic_nack(self, delivery_tag=0, multiple=False, requeue=True): needs to republish the offending messages. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool multiple: Reject multiple messages - :param bool requeue: Requeue the message + :param multiple: Reject multiple messages + :param requeue: Requeue the message """ - self._write_frame(spec.Basic.Nack(delivery_tag, multiple, requeue)) - - def basic_publish(self, exchange='', routing_key='', body='', - properties=None, mandatory=False, immediate=False): + self._write_frame(commands.Basic.Nack(delivery_tag, multiple, requeue)) + + def basic_publish(self, + exchange: str = '', + routing_key: str = '', + body: str = '', + properties: typing.Optional[dict, bytes] = None, + mandatory: bool = False, + immediate: bool = False) \ + -> typing.Union[bool, None]: """Publish a message This method publishes a message to a specific exchange. The message @@ -148,21 +161,22 @@ def basic_publish(self, exchange='', routing_key='', body='', distributed to any active consumers when the transaction, if any, is committed. - :param str exchange: The exchange name - :param str routing_key: Message routing key + :param exchange: The exchange name + :param routing_key: Message routing key :param body: The message body - :type body: str|bytes - :param dict properties: AMQP message properties - :param bool mandatory: Indicate mandatory routing - :param bool immediate: Request immediate delivery - :return: bool or None + :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) + msg = message.Message(self.channel, body, properties or {}, False, + False) return msg.publish(exchange, routing_key, mandatory, immediate) - def basic_qos(self, prefetch_size=0, prefetch_count=0, global_flag=False): + 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 @@ -173,14 +187,16 @@ def basic_qos(self, prefetch_size=0, prefetch_count=0, global_flag=False): for the server. :param prefetch_size: Prefetch window in octets - :type prefetch_size: int|long - :param int prefetch_count: Prefetch window in messages - :param bool global_flag: Apply to entire connection + :param prefetch_count: Prefetch window in messages + :param global_flag: Apply to entire connection """ - self._rpc(spec.Basic.Qos(prefetch_size, prefetch_count, global_flag)) + self._rpc( + commands.Basic.Qos(prefetch_size, prefetch_count, global_flag)) - def basic_reject(self, delivery_tag=0, requeue=True): + 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 @@ -188,102 +204,128 @@ def basic_reject(self, delivery_tag=0, requeue=True): messages to their original queue. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool requeue: Requeue the message + :param requeue: Requeue the message """ - self._write_frame(spec.Basic.Reject(delivery_tag, requeue)) + self._write_frame(commands.Basic.Reject(delivery_tag, requeue)) - def basic_recover(self, requeue=False): + 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 bool requeue: Requeue the message + :param requeue: Requeue the message """ - self._rpc(spec.Basic.Recover(requeue)) + self._rpc(commands.Basic.Recover(requeue)) - def confirm_select(self): + 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(spec.Confirm.Select()) - - def exchange_declare(self, exchange='', exchange_type='direct', - passive=False, durable=False, auto_delete=False, - internal=False, nowait=False, arguments=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: typing.Optional[dict] = 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 str exchange: The exchange name - :param str exchange_type: Exchange type - :param bool passive: Do not create exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param bool internal: Deprecated - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :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(spec.Exchange.Declare(0, exchange, exchange_type, passive, - durable, auto_delete, internal, nowait, - arguments)) - - def exchange_delete(self, exchange='', if_unused=False, - nowait=False): + 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 str exchange: The exchange name - :param bool if_unused: Delete only if unused - :param bool nowait: Do not send a reply method + :param exchange: The exchange name + :param if_unused: Delete only if unused + :param nowait: Do not send a reply method """ - self._rpc(spec.Exchange.Delete(0, exchange, if_unused, nowait)) - - def exchange_bind(self, destination='', source='', - routing_key='', nowait=False, arguments=None): + 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: typing.Optional[dict] = None) \ + -> None: """Bind exchange to an exchange. This method binds an exchange to an exchange. - :param str destination: The destination exchange name - :param str source: The source exchange name - :param str routing_key: The routing key to bind with - :param bool nowait: Do not send a reply method - :param dict arguments: Optional arguments + :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(spec.Exchange.Bind(0, destination, source, routing_key, - nowait, arguments)) - - def exchange_unbind(self, destination='', source='', - routing_key='', nowait=False, arguments=None): + 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: typing.Optional[dict] = None) \ + -> None: """Unbind an exchange from an exchange. This method unbinds an exchange from an exchange. - :param str destination: The destination exchange name - :param str source: The source exchange name - :param str routing_key: The routing key to bind with - :param bool nowait: Do not send a reply method - :param dict arguments: Optional arguments + :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(spec.Exchange.Unbind(0, destination, source, routing_key, - nowait, arguments)) + self._rpc( + commands.Exchange.Unbind(0, destination, source, routing_key, + nowait, arguments)) - def queue_bind(self, queue='', exchange='', routing_key='', - nowait=False, arguments=None): + def queue_bind(self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: typing.Optional[dict] = None) \ + -> None: """Bind queue to an exchange This method binds a queue to an exchange. Until a queue is bound it @@ -291,81 +333,96 @@ def queue_bind(self, queue='', exchange='', routing_key='', forward queues are bound to a direct exchange and subscription queues are bound to a topic exchange. - :param str queue: The queue name - :param str exchange: Name of the exchange to bind to - :param str routing_key: Message routing key - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for binding + :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(spec.Queue.Bind(0, queue, exchange, routing_key, nowait, - arguments)) - - def queue_declare(self, queue='', passive=False, durable=False, - exclusive=False, auto_delete=False, nowait=False, - arguments=None): + 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: typing.Optional[dict] = None) \ + -> None: """Declare queue, create if needed This method creates or checks a queue. When creating a new queue the - client can specify various properties that control the durability of + client can specific various properties that control the durability of the queue and its contents, and the level of sharing for the queue. - :param str queue: The queue name - :param bool passive: Do not create queue - :param bool durable: Request a durable queue - :param bool exclusive: Request an exclusive queue - :param bool auto_delete: Auto-delete queue when unused - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :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(spec.Queue.Declare(0, queue, passive, durable, exclusive, - auto_delete, nowait, arguments)) - - def queue_delete(self, queue='', if_unused=False, if_empty=False, - nowait=False): + 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 str queue: The queue name - :param bool if_unused: Delete only if unused - :param bool if_empty: Delete only if empty - :param bool nowait: Do not send a reply method + :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(spec.Queue.Delete(0, queue, if_unused, if_empty, nowait)) + self._rpc(commands.Queue.Delete(0, queue, if_unused, if_empty, nowait)) - def queue_purge(self, queue='', nowait=False): + 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 str queue: The queue name - :param bool nowait: Do not send a reply method + :param queue: The queue name + :param nowait: Do not send a reply method """ - self._rpc(spec.Queue.Purge(0, queue, nowait)) - - def queue_unbind(self, queue='', exchange='', routing_key='', - arguments=None): + self._rpc(commands.Queue.Purge(0, queue, nowait)) + + def queue_unbind(self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + arguments: typing.Optional[dict] = None) \ + -> None: """Unbind a queue from an exchange This method unbinds a queue from an exchange. - :param str queue: The queue name - :param str exchange: The exchange name - :param str routing_key: Routing key of binding - :param dict arguments: Arguments of binding + :param queue: The queue name + :param exchange: The exchange name + :param routing_key: Routing key of binding + :param arguments: Arguments of binding """ - self._rpc(spec.Queue.Unbind(0, queue, exchange, routing_key, - arguments)) + self._rpc( + commands.Queue.Unbind(0, queue, exchange, routing_key, arguments)) - def tx_select(self): + def tx_select(self) -> None: """Select standard transaction mode This method sets the channel to use standard transactions. The client @@ -373,9 +430,9 @@ def tx_select(self): or Rollback methods. """ - self._rpc(spec.Tx.Select()) + self._rpc(commands.Tx.Select()) - def tx_commit(self): + def tx_commit(self) -> None: """Commit the current transaction This method commits all message publications and acknowledgments @@ -383,16 +440,16 @@ def tx_commit(self): immediately after a commit. """ - self._rpc(spec.Tx.Commit()) + self._rpc(commands.Tx.Commit()) - def tx_rollback(self): + 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 unacked messages will not be + 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(spec.Tx.Rollback()) + self._rpc(commands.Tx.Rollback()) diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index 6cc48a8..7632885 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -27,13 +27,12 @@ """ import logging -import warnings +import typing -from pamqp import specification +from pamqp import commands -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import utils +from rabbitpy import (base, channel as chan, exceptions, exchange, message, + utils) LOGGER = logging.getLogger(__name__) @@ -42,23 +41,18 @@ class Queue(base.AMQPClass): """Create and manage RabbitMQ queues. :param channel: The channel object to communicate on - :type channel: :py:class:`~rabbitpy.Channel` - :param str name: The name of the queue - :param exclusive: Queue can only be used by this channel and will + :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. - :type exclusive: bool :param durable: Indicates if the queue should survive a RabbitMQ is restart - :type durable: bool - :param bool auto_delete: Automatically delete when all consumers disconnect - :param int max_length: Maximum queue length - :param int message_ttl: Time-to-live of a message in milliseconds - :param expires: Milliseconds until a queue is removed after becoming idle - :type expires: int + :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 - :type dead_letter_exchange: str :param dead_letter_routing_key: Routing key for dead lettered messages - :type dead_letter_routing_key: str - :param dict arguments: Custom arguments for the queue + :param arguments: Custom arguments for the queue :attributes: - **consumer_tag** (*str*) – Contains the consumer tag used to register @@ -79,11 +73,18 @@ class Queue(base.AMQPClass): message_ttl = None # pylint: disable=too-many-arguments - def __init__(self, channel, name='', - durable=False, exclusive=False, auto_delete=False, - max_length=None, message_ttl=None, expires=None, - dead_letter_exchange=None, dead_letter_routing_key=None, - arguments=None): + def __init__(self, + channel: chan.Channel, + name: str = '', + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + max_length: typing.Optional[int] = None, + message_ttl: typing.Optional[int] = None, + expires: typing.Optional[int] = None, + dead_letter_exchange: typing.Optional[str] = None, + dead_letter_routing_key: typing.Optional[str] = None, + arguments: typing.Optional[dict] = None): """Create a new Queue object instance. Only the :py:class:`rabbitpy.Channel` object is required. @@ -110,7 +111,7 @@ def __init__(self, channel, name='', self.dead_letter_exchange = dead_letter_exchange self.dead_letter_routing_key = dead_letter_routing_key - def __iter__(self): + def __iter__(self) -> typing.Iterable[message.Message]: """Quick way to consume messages using defaults of ``no_ack=False``, prefetch and priority not set. @@ -123,64 +124,65 @@ def __iter__(self): """ return self.consume() - def __len__(self): + def __len__(self) -> int: """Return the pending number of messages in the queue by doing a passive Queue declare. - :rtype: int - """ response = self._rpc(self._declare(True)) return response.message_count - def __setattr__(self, name, value): + 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 str name: The attribute to set - :param mixed value: The value to set + :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('%s must be True or False' % name) - elif (name in ['max_length', 'message_ttl', 'expires'] and - not isinstance(value, int)): - raise ValueError('%s must be an int' % name) - elif (name in ['consumer_tag', - 'dead_letter_exchange', - 'dead_letter_routing_key'] and - not utils.is_string(value)): - raise ValueError('%s must be a str, bytes or unicode' % name) + 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') - - # Set the value super(Queue, self).__setattr__(name, value) - def bind(self, source, routing_key=None, arguments=None): + def bind(self, + source: typing.Union[str, exchange.Exchange], + routing_key: typing.Optional[str] = None, + arguments: typing.Optional[dict] = None) -> bool: """Bind the queue to the specified exchange or routing key. - :type source: str or :py:class:`rabbitpy.exchange.Exchange` exchange :param source: The exchange to bind to - :param str routing_key: The routing key to use - :param dict arguments: Optional arguments for for RabbitMQ - :return: bool + :param routing_key: The routing key to use + :param arguments: Optional arguments for RabbitMQ """ if hasattr(source, 'name'): source = source.name - frame = specification.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, specification.Queue.BindOk) - - def consume(self, no_ack=False, prefetch=None, priority=None, - consumer_tag=None): + return isinstance(response, commands.Queue.BindOk) + + def consume(self, + no_ack: bool = False, + prefetch: typing.Optional[int] = None, + priority: typing.Optional[int] = None, + consumer_tag: typing.Optional[str] = None) \ + -> typing.Generator[message.Message, None, None]: """Consume messages from the queue as a :py:class:`generator`: .. code:: python @@ -191,29 +193,25 @@ def consume(self, no_ack=False, prefetch=None, priority=None, if you need to alter the prefect count, set the consumer priority or consume in no_ack mode. - .. versionadded:: 0.26 - .. 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 bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :param str consumer_tag: Optional consumer tag - :rtype: :py:class:`generator` + :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._consume(no_ack, prefetch, priority) + self._register_consumer(no_ack, prefetch, priority) try: while self.consuming: - # pylint: disable=protected-access - message = self.channel._consume_message() - if message: - yield message + value = self.channel.consume_message() + if value: + yield value else: if self.consuming: self.stop_consuming() @@ -222,48 +220,7 @@ def consume(self, no_ack=False, prefetch=None, priority=None, if self.consuming: self.stop_consuming() - def consume_messages(self, no_ack=False, prefetch=None, priority=None): - """Consume messages from the queue as a generator. - - .. warning:: This method is deprecated in favor of - :py:meth:`Queue.consume` and will be removed in future releases. - - .. deprecated:: 0.26 - - You can use this message 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. - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :rtype: :py:class:`Generator` - :raises: :exc:`~rabbitpy.exceptions.RemoteCancellationException` - - """ - warnings.warn('This method is deprecated in favor Queue.consume', - DeprecationWarning) - return self.consume(no_ack, prefetch, priority) - - # pylint: disable=no-self-use, unused-argument - def consumer(self, no_ack=False, prefetch=None, priority=None): - """Method for returning the contextmanager for consuming messages. You - should not use this directly. - - .. warning:: This method is deprecated and will be removed in a future - release. - - .. deprecated:: 0.26 - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :return: None - - """ - raise DeprecationWarning() - - def declare(self, passive=False): + def declare(self, passive: bool = False) -> typing.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. @@ -279,18 +236,20 @@ def declare(self, passive=False): self.name = response.queue return response.message_count, response.consumer_count - def delete(self, if_unused=False, if_empty=False): + def delete(self, if_unused: bool = False, if_empty: bool = False) -> None: """Delete the queue - :param bool if_unused: Delete only if unused - :param bool if_empty: Delete only if empty + :param if_unused: Delete only if unused + :param if_empty: Delete only if empty """ - self._rpc(specification.Queue.Delete(queue=self.name, - if_unused=if_unused, - if_empty=if_empty)) + self._rpc( + commands.Queue.Delete(queue=self.name, + if_unused=if_unused, + if_empty=if_empty)) - def get(self, acknowledge=True): + def get(self, acknowledge: bool = True) \ + -> typing.Union[message.Message, None]: """Request a single message from RabbitMQ using the Basic.Get AMQP command. @@ -299,26 +258,24 @@ def get(self, acknowledge=True): have unintended consequences. - :param bool acknowledge: Let RabbitMQ know if you will manually - acknowledge or negatively acknowledge the - message after each get. - :rtype: :class:`~rabbitpy.Message` or None + :param acknowledge: Let RabbitMQ know if you will manually + acknowledge or negatively acknowledge the + message after each get. """ - self._write_frame(specification.Basic.Get(queue=self.name, - no_ack=not acknowledge)) - - return self.channel._get_message() # pylint: disable=protected-access + self._write_frame( + commands.Basic.Get(queue=self.name, no_ack=not acknowledge)) + return self.channel.get_message() - def ha_declare(self, nodes=None): - """Declare a the queue as highly available, passing in a list of nodes + def ha_declare(self, nodes: typing.Optional[typing.List[str]] = None) \ + -> typing.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 - :rtype: tuple(int, int) """ if nodes: @@ -330,11 +287,11 @@ def ha_declare(self, nodes=None): del self.arguments['x-ha-nodes'] return self.declare() - def purge(self): + def purge(self) -> None: """Purge the queue of all of its messages.""" - self._rpc(specification.Queue.Purge()) + self._rpc(commands.Queue.Purge()) - def stop_consuming(self): + 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. @@ -346,47 +303,33 @@ def stop_consuming(self): return if not self.consuming: raise exceptions.NotConsumingError() - self.channel._cancel_consumer(self) # pylint: disable=protected-access + self.channel.cancel_consumer(self) self.consuming = False - def unbind(self, source, routing_key=None): + def unbind(self, + source: typing.Union[str, exchange.Exchange], + routing_key: typing.Optional[str] = None) -> None: """Unbind queue from the specified exchange where it is bound the routing key. If routing key is None, use the queue name. - :type source: str or :py:class:`rabbitpy.exchange.Exchange` exchange :param source: The exchange to unbind from - :param str routing_key: The routing key that binds them + :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(specification.Queue.Unbind(queue=self.name, exchange=source, - routing_key=routing_key)) - - def _consume(self, no_ack=False, prefetch=None, priority=None): - """Return a :py:class:_Consumer instance as a contextmanager, properly - shutting down the consumer when the generator is exited. - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :return: _Consumer - - """ - if prefetch: - self.channel.prefetch_count(prefetch, False) - # pylint: disable=protected-access - self.channel._consume(self, no_ack, priority) - self.consuming = True + self._rpc( + commands.Queue.Unbind(queue=self.name, + exchange=source, + routing_key=routing_key)) - def _declare(self, passive=False): - """Return a specification.Queue.Declare class pre-composed for the rpc + 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 bool passive: Passive declare to retrieve message count and - consumer count information - :rtype: pamqp.specification.Queue.Declare + :param passive: Passive declare to retrieve message count and + consumer count information """ arguments = dict(self.arguments) @@ -402,13 +345,29 @@ def _declare(self, passive=False): 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 specification.Queue.Declare(queue=self.name, - durable=self.durable, - passive=passive, - exclusive=self.exclusive, - auto_delete=self.auto_delete, - arguments=arguments) + 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: typing.Optional[int] = None, + priority: typing.Optional[int] = 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 index 57d4a9a..3072f25 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -8,7 +8,7 @@ import threading import typing -from pamqp import base, body, commands, header +from pamqp import base, body, commands, header, heartbeat from rabbitpy import (connection as conn, channel as chan, exceptions, message, utils) @@ -39,7 +39,11 @@ def __init__(self, channel: chan.Channel): self.channel = channel def _rpc(self, frame_value: base.Frame) \ - -> typing.Union[base.Frame, message.Message]: + -> typing.Union[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 @@ -140,6 +144,7 @@ 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 @@ -200,7 +205,9 @@ def close(self) -> None: LOGGER.debug('Channel #%i closed', self._channel_id) self._set_state(self.CLOSED) - def rpc(self, frame_value: base.Frame) -> typing.Union[base.Frame]: + def rpc(self, frame_value: base.Frame) \ + -> typing.Union[base.Frame, + commands.Queue.DeclareOk]: """Send an RPC command to the remote server. This should not be directly invoked. @@ -222,7 +229,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) -> 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. @@ -254,8 +261,8 @@ def write_frames(self, frames: typing.List[base.Frame]) -> None: def _build_close_frame(self) -> commands.Channel.Close: """Return the proper close frame for this object.""" - return commands.Channel.Close(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() @@ -347,7 +354,8 @@ def _on_remote_close(self, value: commands.Channel.Close) -> None: raise exceptions.RemoteClosedChannelException( self._channel_id, value.reply_code, value.reply_text) - def _read_from_queue(self) -> typing.Union[base.Frame, None]: + def _read_from_queue(self) \ + -> typing.Union[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: @@ -400,6 +408,8 @@ def _validate_frame_type(self, frame_value: base.Frame, def _wait_on_frame(self, frame_type: FrameTypes) \ -> typing.Union[base.Frame, + commands.Basic.Deliver, + commands.Queue.DeclareOk, header.ContentHeader, body.ContentBody]: """Read from the queue, blocking until a result is returned. An diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index ad51436..d336db5 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -11,7 +11,7 @@ import types import typing -from pamqp import base as pamqp_base, body as pamqp_body, commands, header +from pamqp import base as pamqp_base, commands, header from rabbitpy import (amqp, amqp_queue, base, connection as conn, events as rabbitpy_events, exceptions, message) @@ -51,7 +51,7 @@ class Channel(base.AMQPChannel): :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 + :param blocking_read: Use blocking `Queue.get` to improve performance :raises: rabbitpy.exceptions.RemoteClosedChannelException :raises: rabbitpy.exceptions.AMQPException @@ -248,6 +248,34 @@ def recover(self, requeue: bool = False) -> None: """ self.rpc(commands.Basic.Recover(requeue=requeue)) + def register_consumer(self, + obj: amqp_queue.Queue, + no_ack: bool, + priority: typing.Optional[int] = 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 = dict() + 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""" @@ -269,7 +297,7 @@ def _on_ready_to_cancel(self, consumer_tag: str, nowait: bool) -> None: 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 a RPC request from RabbitMQ.""" + """Inspect a frame to see if it's an RPC request from RabbitMQ.""" LOGGER.debug('Checking for RPC request: %r', value) super(Channel, self)._check_for_rpc_request(value) if isinstance(value, commands.Basic.Return): @@ -283,32 +311,6 @@ def _check_for_rpc_request(self, value: pamqp_base.Frame) -> None: del self.consumers[value.consumer_tag] raise exceptions.RemoteCancellationException(value.consumer_tag) - def _consume(self, - obj: amqp_queue.Queue, - no_ack: bool, - priority: typing.Optional[int] = None) -> None: - """Register a Queue object as a consumer, issuing Basic.Consume. - - :param obj: The queue to consume - :param no_ack: no_ack mode - :param priority: Consumer priority - :raises: ValueError - - """ - args = dict() - 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) - def _create_message(self, method_frame: typing.Optional[pamqp_base.Frame], header_frame: typing.Optional[header.ContentHeader], @@ -350,8 +352,12 @@ def _get_from_read_queue(self) -> typing.Union[pamqp_base.Frame, None]: pass return frame_value - def _get_message(self) -> typing.Union[message.Message, None]: - """Try and get a delivered message from the connection's stack.""" + def get_message(self) -> typing.Union[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): diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index 2e475fe..bf7087c 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -5,17 +5,14 @@ """ import locale import logging +import queue +import socket import sys +import typing -from pamqp import header -from pamqp import heartbeat -from pamqp import specification +from pamqp import base as pamqp_base, commands, header, heartbeat -from rabbitpy import __version__ -from rabbitpy import base -from rabbitpy import events -from rabbitpy import exceptions -from rabbitpy.utils import queue +from rabbitpy import __version__, connection as conn, base, events, exceptions LOGGER = logging.getLogger(__name__) DEFAULT_LOCALE = locale.getdefaultlocale() @@ -26,26 +23,27 @@ class Channel0(base.AMQPChannel): """Channel0 is used to negotiate a connection with RabbitMQ and for processing and dispatching events on channel 0 once connected. - :param dict connection_args: Data required to negotiate the connection + :param connection_args: Data required to negotiate the connection :param events_obj: The shared events coordination object - :type events_obj: rabbitpy.events.Events :param exception_queue: The queue where any pending exceptions live - :type exception_queue: queue.Queue :param write_queue: The queue to place data to write in - :type write_queue: queue.Queue :param write_trigger: The socket to write to, to trigger IO writes - :type write_trigger: socket.socket """ CHANNEL = 0 - CLOSE_REQUEST_FRAME = specification.Connection.Close + CLOSE_REQUEST_FRAME = commands.Connection.Close DEFAULT_LOCALE = 'en-US' - def __init__(self, connection_args, events_obj, exception_queue, - write_queue, write_trigger, connection): - super(Channel0, self).__init__( - exception_queue, write_trigger, connection) + def __init__(self, + connection_args: dict, + events_obj: events.Events, + exception_queue: queue.Queue, + write_queue: queue.Queue, + write_trigger: socket.socket, + connection: conn.Connection): + super(Channel0, self).__init__(exception_queue, write_trigger, + connection) self._channel_id = 0 self._args = connection_args self._events = events_obj @@ -57,13 +55,13 @@ def __init__(self, connection_args, events_obj, exception_queue, self._max_channels = connection_args['channel_max'] self._max_frame_size = connection_args['frame_max'] self._heartbeat_interval = connection_args['heartbeat'] - self.properties = None + self.properties: typing.Optional[dict] = None def close(self): """Close the connection via Channel0 communication.""" if self.open: self._set_state(self.CLOSING) - self.rpc(specification.Connection.Close()) + self.rpc(commands.Connection.Close()) @property def heartbeat_interval(self): @@ -92,12 +90,12 @@ def maximum_frame_size(self): """ return self._max_frame_size - def on_frame(self, value): - """Process a RPC frame received from the server - - :param pamqp.message.Message value: The message value - - """ + def on_frame(self, value: typing.Union[commands.Connection.Blocked, + commands.Connection.Close, + commands.Connection.Start, + commands.Connection.Tune, + pamqp_base.Frame]) -> None: + """Process an RPC frame received from the server""" LOGGER.debug('Received frame: %r', value.name) if value.name == 'Connection.Close': LOGGER.warning('RabbitMQ closed the connection (%s): %s', @@ -133,157 +131,137 @@ def on_frame(self, value): pass else: LOGGER.warning('Unexpected Channel0 Frame: %r', value) - raise specification.AMQPUnexpectedFrame(value) + raise commands.AMQPUnexpectedFrame(value) - def send_heartbeat(self): + def send_heartbeat(self) -> None: """Send a heartbeat frame to the remote connection.""" self.write_frame(heartbeat.Heartbeat()) - def start(self): + def start(self) -> None: """Start the AMQP protocol negotiation""" self._set_state(self.OPENING) self._write_protocol_header() - def _build_open_frame(self): - """Build and return the Connection.Open frame. + def _build_open_frame(self) -> commands.Connection.Open: + """Build and return the Connection.Open frame.""" + return commands.Connection.Open(self._args['virtual_host']) - :rtype: pamqp.specification.Connection.Open - - """ - return specification.Connection.Open(self._args['virtual_host']) - - def _build_start_ok_frame(self): - """Build and return the Connection.StartOk frame. - - :rtype: pamqp.specification.Connection.StartOk - - """ + def _build_start_ok_frame(self) -> commands.Connection.StartOk: + """Build and return the Connection.StartOk frame.""" properties = { 'product': 'rabbitpy', 'platform': 'Python {0}.{1}.{2}'.format(*sys.version_info), - 'capabilities': {'authentication_failure_close': True, - 'basic.nack': True, - 'connection.blocked': True, - 'consumer_cancel_notify': True, - 'publisher_confirms': True}, + 'capabilities': { + 'authentication_failure_close': True, + 'basic.nack': True, + 'connection.blocked': True, + 'consumer_cancel_notify': True, + 'publisher_confirms': True + }, 'information': 'See https://rabbitpy.readthedocs.io', - 'version': __version__} - return specification.Connection.StartOk(client_properties=properties, - response=self._credentials, - locale=self._get_locale()) - - def _build_tune_ok_frame(self): - """Build and return the Connection.TuneOk frame. - - :rtype: pamqp.specification.Connection.TuneOk - - """ - return specification.Connection.TuneOk(self._max_channels, - self._max_frame_size, - self._heartbeat_interval) + 'version': __version__ + } + return commands.Connection.StartOk( + client_properties=properties, + response=self._credentials, + locale=self._get_locale()) + + def _build_tune_ok_frame(self) -> commands.Connection.TuneOk: + """Build and return the Connection.TuneOk frame.""" + return commands.Connection.TuneOk( + self._max_channels, self._max_frame_size, self._heartbeat_interval) @property - def _credentials(self): - """Return the marshaled credentials for the AMQP connection. - - :rtype: str - - """ + def _credentials(self) -> str: + """Return the marshaled credentials for the AMQP connection.""" return '\0%s\0%s' % (self._args['username'], self._args['password']) - def _get_locale(self): + def _get_locale(self) -> str: """Return the current locale for the python interpreter or the default locale. - :rtype: str - """ if not self._args['locale']: return DEFAULT_LOCALE[0] or self.DEFAULT_LOCALE return self._args['locale'] @staticmethod - def _negotiate(client_value, server_value): + def _negotiate(client_value: int, server_value: int) -> int: """Return the negotiated value between what the client has requested and the server has requested for how the two will communicate. - :param int client_value: - :param int server_value: - :return: int + :param client_value: + :param server_value: """ - return min(client_value, server_value) or \ - (client_value or server_value) + return (min(client_value, server_value) or + (client_value or server_value)) - def _on_connection_open_ok(self): + def _on_connection_open_ok(self) -> None: LOGGER.debug('Connection opened') self._set_state(self.OPEN) self._events.set(events.CHANNEL0_OPENED) - def _on_connection_start(self, frame_value): + def _on_connection_start(self, value: commands.Connection.Start) -> None: """Negotiate the Connection.Start process, writing out a Connection.StartOk frame when the Connection.Start frame is received. - :type frame_value: pamqp.specification.Connection.Start :raises: rabbitpy.exceptions.ConnectionException """ - if not self._validate_connection_start(frame_value): + if not self._validate_connection_start(value): LOGGER.error('Could not negotiate a connection, disconnecting') raise exceptions.ConnectionResetException() - self.properties = frame_value.server_properties + self.properties = value.server_properties for key in self.properties: if key == 'capabilities': for capability in self.properties[key]: - LOGGER.debug('Server supports %s: %r', - capability, self.properties[key][capability]) + LOGGER.debug('Server supports %s: %r', capability, + self.properties[key][capability]) else: LOGGER.debug('Server %s: %r', key, self.properties[key]) self.write_frame(self._build_start_ok_frame()) - def _on_connection_tune(self, frame_value): + def _on_connection_tune(self, value: commands.Connection.Tune) -> None: """Negotiate the Connection.Tune frames, waiting for the Connection.Tune frame from RabbitMQ and sending the Connection.TuneOk frame. - :param specification.Connection.Tune frame_value: Tune frame - """ - self._max_frame_size = self._negotiate(self._max_frame_size, - frame_value.frame_max) - self._max_channels = self._negotiate(self._max_channels, - frame_value.channel_max) + self._max_frame_size = self._negotiate( + self._max_frame_size, value.frame_max) + self._max_channels = self._negotiate( + self._max_channels, value.channel_max) LOGGER.debug('Heartbeat interval (server/client): %r/%r', - frame_value.heartbeat, self._heartbeat_interval) + value.heartbeat, self._heartbeat_interval) # Properly negotiate the heartbeat interval if self._heartbeat_interval is None: - self._heartbeat_interval = frame_value.heartbeat - elif self._heartbeat_interval == 0 or frame_value.heartbeat == 0: + self._heartbeat_interval = value.heartbeat + elif self._heartbeat_interval == 0 or value.heartbeat == 0: self._heartbeat_interval = 0 self.write_frame(self._build_tune_ok_frame()) self.write_frame(self._build_open_frame()) @staticmethod - def _validate_connection_start(frame_value): + def _validate_connection_start(value: commands.Connection.Start) -> bool: """Validate the received Connection.Start frame - :param specification.Connection.Start frame_value: Frame to validate + :param value: Frame to validate :rtype: bool """ - if (frame_value.version_major, frame_value.version_minor) != \ - (specification.VERSION[0], specification.VERSION[1]): + if (value.version_major, value.version_minor) != \ + (commands.VERSION[0], commands.VERSION[1]): LOGGER.warning('AMQP version error (received %i.%i, expected %r)', - frame_value.version_major, - frame_value.version_minor, - specification.VERSION) + value.version_major, + value.version_minor, commands.VERSION) return False return True - def _write_protocol_header(self): + def _write_protocol_header(self) -> None: """Send the protocol header to the connected server.""" self.write_frame(header.ProtocolHeader()) From 6e62510c17c2b36ffaa37fa4b80ec171587a7384 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 15:21:35 -0400 Subject: [PATCH 06/59] Add type annotations and update to pamqp 3.0 --- rabbitpy/tx.py | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py index 5b8d415..10e23c8 100644 --- a/rabbitpy/tx.py +++ b/rabbitpy/tx.py @@ -4,10 +4,12 @@ """ import logging -from pamqp import specification as spec +import types +import typing -from rabbitpy import base -from rabbitpy import exceptions +from pamqp import commands + +from rabbitpy import base, channel as chan, exceptions LOGGER = logging.getLogger(__name__) @@ -26,24 +28,25 @@ class Tx(base.AMQPClass): mandatory flags on Basic.Publish methods is not defined. :param channel: The channel object to start the transaction on - :type channel: :py:class:`rabbitpy.channel.Channel` """ - def __init__(self, channel): + + def __init__(self, channel: chan.Channel): super(Tx, self).__init__(channel, 'Tx') self._selected = False - def __enter__(self): + def __enter__(self) -> 'Tx': """For use as a context manager, return a handle to this object instance. - :rtype: Connection - """ self.select() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, + exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + unused_exc_tb: typing.Optional[types.TracebackType]): """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -58,22 +61,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self._selected: self.commit() - def select(self): + 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. - :rtype: bool - """ - response = self._rpc(spec.Tx.Select()) - result = isinstance(response, spec.Tx.SelectOk) - self._selected = result - return result + response = self._rpc(commands.Tx.Select()) + self._selected = isinstance(response, commands.Tx.SelectOk) + return self._selected - def commit(self): + def commit(self) -> bool: """Commit the current transaction This method commits all message publications and acknowledgments @@ -81,18 +81,17 @@ def commit(self): immediately after a commit. :raises: rabbitpy.exceptions.NoActiveTransactionError - :rtype: bool """ try: - response = self._rpc(spec.Tx.Commit()) + response = self._rpc(commands.Tx.Commit()) except exceptions.ChannelClosedException as error: LOGGER.warning('Error committing transaction: %s', error) raise exceptions.NoActiveTransactionError() self._selected = False - return isinstance(response, spec.Tx.CommitOk) + return isinstance(response, commands.Tx.CommitOk) - def rollback(self): + def rollback(self) -> bool: """Abandon the current transaction This method abandons all message publications and acknowledgments @@ -102,13 +101,12 @@ def rollback(self): recover call should be issued. :raises: rabbitpy.exceptions.NoActiveTransactionError - :rtype: bool """ try: - response = self._rpc(spec.Tx.Rollback()) + response = self._rpc(commands.Tx.Rollback()) except exceptions.ChannelClosedException as error: LOGGER.warning('Error rolling back transaction: %s', error) raise exceptions.NoActiveTransactionError() self._selected = False - return isinstance(response, spec.Tx.RollbackOk) + return isinstance(response, commands.Tx.RollbackOk) From 76e1aab7d9d317a388293a056586101f3b52b1b0 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 15:41:11 -0400 Subject: [PATCH 07/59] Add type annotations --- rabbitpy/simple.py | 212 +++++++++++++++++++++++++-------------------- 1 file changed, 119 insertions(+), 93 deletions(-) diff --git a/rabbitpy/simple.py b/rabbitpy/simple.py index df99ef4..80ad745 100644 --- a/rabbitpy/simple.py +++ b/rabbitpy/simple.py @@ -1,15 +1,15 @@ """ Wrapper methods for easy access to common operations, making them both less -complex and less verbose for one off or simple use cases. +complex and less verbose for one-off or simple use cases. """ -from rabbitpy import amqp_queue -from rabbitpy import connection -from rabbitpy import exchange -from rabbitpy import message +import types +import typing +from rabbitpy import amqp_queue, channel, connection, exchange, message -class SimpleChannel(object): + +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. @@ -27,17 +27,21 @@ class SimpleChannel(object): :class:`~rabbitpy.connection.Connection` class documentation. """ - def __init__(self, uri): + + def __init__(self, uri: str): self.connection = None self.channel = None self.uri = uri - def __enter__(self): + def __enter__(self) -> channel.Channel: self.connection = connection.Connection(self.uri) self.channel = self.connection.channel() return self.channel - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, + exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + unused_exc_tb: typing.Optional[types.TracebackType]): if not self.channel.closed: self.channel.close() if not self.connection.closed: @@ -46,107 +50,116 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise -def consume(uri=None, queue_name=None, no_ack=False, prefetch=None, - priority=None): +def consume(uri: typing.Optional[str] = None, + queue_name: typing.Optional[str] = None, + no_ack: typing.Optional[bool] = False, + prefetch: typing.Optional[int] = None, + priority: typing.Optional[int] = 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'): + for message in rabbitpy.consume('amqp://localhost/%2F', 'my-queue'): message.ack() - :param str uri: AMQP connection URI - :param str queue_name: The name of the queue to consume from - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Set the consumer priority - :rtype: :py:class:`Iterator` + :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 channel: - queue = amqp_queue.Queue(channel, queue_name) + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) for msg in queue.consume(no_ack, prefetch, priority): yield msg -def get(uri=None, queue_name=None): +def get(uri: typing.Optional[str] = None, + queue_name: typing.Optional[str] = None) \ + -> typing.Union[message.Message, None]: """Get a message from RabbitMQ, auto-acknowledging with RabbitMQ if one is returned. Invoke directly as ``rabbitpy.get()`` - :param str uri: AMQP URI to connect to - :param str queue_name: The queue name to get the message from - :rtype: py:class:`rabbitpy.message.Message` or None + :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 channel: - queue = amqp_queue.Queue(channel, queue_name) + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) return queue.get(False) -def publish(uri=None, exchange_name=None, routing_key=None, - body=None, properties=None, confirm=False): +def publish(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None, + routing_key: typing.Optional[str] = None, + body: typing.Optional[bytes, str] = None, + properties: typing.Optional[dict] = None, + confirm: bool = False) -> typing.Union[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 str uri: AMQP URI to connect to - :param str exchange_name: The exchange to publish to - :param str routing_key: The routing_key to publish with + :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 - :type body: str or unicode or bytes or dict or list - :param dict properties: Dict representation of Basic.Properties - :param bool confirm: Confirm this delivery with Publisher Confirms - :rtype: bool or None + :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 channel: - msg = message.Message(channel, body or '', properties or dict()) + with SimpleChannel(uri) as chan: + msg = message.Message(chan, body or '', properties or dict()) if confirm: - channel.enable_publisher_confirms() - return msg.publish(exchange_name, routing_key or '', - mandatory=True) + 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=None, queue_name='', durable=True, auto_delete=False, - max_length=None, message_ttl=None, expires=None, - dead_letter_exchange=None, dead_letter_routing_key=None, - arguments=None): +def create_queue(uri: typing.Optional[str] = None, + queue_name: str = '', + durable: bool = True, + auto_delete: bool = False, + max_length: typing.Optional[int] = None, + message_ttl: typing.Optional[int] = None, + expires: typing.Optional[int] = None, + dead_letter_exchange: typing.Optional[str] = None, + dead_letter_routing_key: typing.Optional[str] = None, + arguments: typing.Optional[dict] = 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 str uri: AMQP URI to connect to - :param str queue_name: The queue name to create + :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 - :type durable: bool - :param bool auto_delete: Automatically delete when all consumers disconnect - :param int max_length: Maximum queue length - :param int message_ttl: Time-to-live of a message in milliseconds + :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 - :type expires: int :param dead_letter_exchange: Dead letter exchange for rejected messages - :type dead_letter_exchange: str :param dead_letter_routing_key: Routing key for dead lettered messages - :type dead_letter_routing_key: str - :param dict arguments: Custom arguments for the queue + :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 channel: - amqp_queue.Queue(channel, queue_name, + with SimpleChannel(uri) as c: + amqp_queue.Queue(c, + queue_name, durable=durable, auto_delete=auto_delete, max_length=max_length, @@ -157,29 +170,31 @@ def create_queue(uri=None, queue_name='', durable=True, auto_delete=False, arguments=arguments).declare() -def delete_queue(uri=None, queue_name=None): +def delete_queue(uri: typing.Optional[str] = None, + queue_name: str = '') -> None: """Delete a queue from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str queue_name: The queue name to delete - :rtype: bool + :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 channel: - amqp_queue.Queue(channel, queue_name).delete() + with SimpleChannel(uri) as c: + amqp_queue.Queue(c, queue_name).delete() -def create_direct_exchange(uri=None, exchange_name=None, durable=True): +def create_direct_exchange(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None, + durable: bool = True) -> None: """Create a direct exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :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` @@ -187,13 +202,15 @@ def create_direct_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.DirectExchange, durable) -def create_fanout_exchange(uri=None, exchange_name=None, durable=True): +def create_fanout_exchange(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None, + durable: bool = True) -> None: """Create a fanout exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :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` @@ -201,13 +218,15 @@ def create_fanout_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.FanoutExchange, durable) -def create_headers_exchange(uri=None, exchange_name=None, durable=True): +def create_headers_exchange(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None, + durable: bool = True) -> None: """Create a headers exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :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` @@ -215,13 +234,15 @@ def create_headers_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.HeadersExchange, durable) -def create_topic_exchange(uri=None, exchange_name=None, durable=True): +def create_topic_exchange(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None, + durable: bool = True) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :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` @@ -229,44 +250,49 @@ def create_topic_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.TopicExchange, durable) -def delete_exchange(uri=None, exchange_name=None): +def delete_exchange(uri: typing.Optional[str] = None, + exchange_name: typing.Optional[str] = None) -> None: """Delete an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to delete + :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 channel: - exchange.Exchange(channel, exchange_name).delete() + with SimpleChannel(uri) as c: + exchange.Exchange(c, exchange_name).delete() -def _create_exchange(uri, exchange_name, exchange_class, durable): +def _create_exchange(uri: typing.Optional[str], + exchange_name: typing.Optional[str], + exchange_class: typing.Callable, + durable: bool) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :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 channel: - exchange_class(channel, exchange_name, durable=durable).declare() + with SimpleChannel(uri) as c: + exchange_class(c, exchange_name, durable=durable).declare() -def _validate_name(value, obj_type): +def _validate_name(value: str, obj_type: str) -> None: """Validate the specified name is set. - :param str value: The value to validate - :param str obj_type: The object type for the error message if needed + :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('You must specify the {} name'.format(obj_type)) + raise ValueError(f'You must specify the {obj_type} name') From a62a66b0959d9cd4567f1e906316ac6c15c73a32 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 16:44:15 -0400 Subject: [PATCH 08/59] Type Annotations, Python3 Improvements, pamqp 3 support --- rabbitpy/message.py | 231 ++++++++++++++++++-------------------------- 1 file changed, 96 insertions(+), 135 deletions(-) diff --git a/rabbitpy/message.py b/rabbitpy/message.py index 2eea572..4d2b687 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -10,31 +10,18 @@ import logging import math import time +import typing import pprint import uuid -from pamqp import body -from pamqp import header -from pamqp import specification +from pamqp import body, commands, common, header -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import utils +from rabbitpy import base, channel as chan, exceptions, exchange as exc, utils LOGGER = logging.getLogger(__name__) -# Python 2.6 does not have a memoryview object, create dummy for isinstance -try: - _PY_VERSION_CHECK = memoryview(b'foo') -except NameError: - # pylint: disable=too-few-public-methods, redefined-builtin - # pylint: disable=invalid-name, missing-docstring - class memoryview(object): - pass - - -class Properties(specification.Basic.Properties): +class Properties(commands.Basic.Properties): """Proxy class for :py:class:`pamqp.specification.Basic.Properties`""" pass @@ -82,25 +69,21 @@ class Message(base.AMQPClass): is not specified when passing in ``properties``, the current Unix epoch value will be set in the message properties. - .. note:: As of 0.21.0 ``auto_id`` is deprecated in favor of - ``opinionated`` and it will be removed in a future version. As of - 0.22.0 ``opinionated`` is defaulted to ``False``. - :param channel: The channel object for the message object to act upon - :type channel: :py:class:`rabbitpy.channel.Channel` :param body_value: The message body - :type body_value: str|bytes|unicode|memoryview|dict|json - :param dict properties: A dictionary of message properties - :param bool auto_id: Add a message id if no properties were passed in. - :param bool opinionated: Automatically populate properties if True + :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, body_value, properties=None, auto_id=False, - opinionated=False): + def __init__(self, + channel: chan.Channel, + body_value: typing.Union[str, bytes, memoryview, dict, list], + properties: typing.Optional[dict] = None, + opinionated: bool = False): """Create a new instance of the Message object.""" super(Message, self).__init__(channel, 'Message') @@ -111,13 +94,10 @@ def __init__(self, channel, body_value, properties=None, auto_id=False, if isinstance(body_value, memoryview): self.body = bytes(body_value) else: - # pylint: disable=redefined-variable-type 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 or auto_id) and 'message_id' not in self.properties: - if auto_id: - raise DeprecationWarning('Use opinionated instead of auto_id') + if opinionated and 'message_id' not in self.properties: self._add_auto_message_id() if opinionated: @@ -131,50 +111,42 @@ def __init__(self, channel, body_value, properties=None, auto_id=False, # Don't let invalid property keys in if self._invalid_properties: - msg = 'Invalid property: %s' % self._invalid_properties[0] - raise KeyError(msg) + raise KeyError('Invalid property: {}'.format( + self._invalid_properties[0])) @property - def delivery_tag(self): + def delivery_tag(self) -> typing.Union[int, None]: """Return the delivery tag for a message that was delivered or gotten from RabbitMQ. - :rtype: int or None - """ return self.method.delivery_tag if self.method else None @property - def redelivered(self): + def redelivered(self) -> typing.Union[bool, None]: """Indicates if this message may have been delivered before (but not - acknowledged)" - - :rtype: bool or None + acknowledged) """ return self.method.redelivered if self.method else None @property - def routing_key(self): + def routing_key(self) -> typing.Union[str, None]: """Return the routing_key for a message that was delivered or gotten from RabbitMQ. - :rtype: int or None - """ return self.method.routing_key if self.method else None @property - def exchange(self): + def exchange(self) -> typing.Union[str, None]: """Return the source exchange for a message that was delivered or gotten from RabbitMQ. - :rtype: string or None - """ return self.method.exchange if self.method else None - def ack(self, all_previous=False): + 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. @@ -182,45 +154,41 @@ def ack(self, all_previous=False): """ if not self.method: - raise exceptions.ActionException('Can not ack non-received ' - 'message') - basic_ack = specification.Basic.Ack(self.method.delivery_tag, - multiple=all_previous) + 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): - """Deserialize the message body if it is JSON, returning the value. - - :rtype: any - - """ + 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=False, all_previous=False): + 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 bool requeue: Requeue the message - :param bool all_previous: Nack all previous unacked messages up to and - including this one + :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 = specification.Basic.Nack(self.method.delivery_tag, - requeue=requeue, - multiple=all_previous) + 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=False): # pragma: no cover + def pprint(self, properties: bool = False) -> None: # pragma: no cover """Print a formatted representation of the message. - :param bool properties: Include properties in the representation + :param properties: Include properties in the representation """ print('Exchange: %s\n' % self.method.exchange) @@ -231,27 +199,23 @@ def pprint(self, properties=False): # pragma: no cover print('\nBody:\n') pprint.pprint(self.body) - def publish(self, exchange, routing_key='', mandatory=False, - immediate=False): + def publish(self, + exchange: typing.Union[str, exc.Exchange], + routing_key: str = '', + mandatory: bool = False, + immediate: bool = False) -> typing.Union[bool, None]: """Publish the message to the exchange with the specified routing key. - In Python 2 if the message is a ``unicode`` value it will be converted - to a ``str`` using ``str.encode('UTF-8')``. If you do not want the - auto-conversion to take place, set the body to a ``str`` or ``bytes`` - value prior to publishing. - - In Python 3 if the message is a ``str`` value it will be converted to + 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 - :type exchange: str or :class:`rabbitpy.Exchange` - :param str routing_key: The routing key to use - :param bool mandatory: Requires the message is published - :param bool immediate: Request immediate delivery - :return: bool or None + :param routing_key: The routing key to use + :param mandatory: Requires the message is published + :param immediate: Request immediate delivery :raises: rabbitpy.exceptions.MessageReturnedException """ @@ -261,16 +225,18 @@ def publish(self, exchange, routing_key='', mandatory=False, # Coerce the body to the proper type payload = utils.maybe_utf8_encode(self.body) - frames = [specification.Basic.Publish(exchange=exchange, - routing_key=routing_key or '', - mandatory=mandatory, - immediate=immediate), - header.ContentHeader(body_size=len(payload), - properties=self._properties)] + 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 = int(math.ceil(len(payload) / - float(self.channel.maximum_frame_size))) + pieces = int( + math.ceil(len(payload) / float(self.channel.maximum_frame_size))) # Send the message for offset in range(0, pieces): @@ -286,49 +252,44 @@ def publish(self, exchange, routing_key='', mandatory=False, # If publisher confirmations are enabled, wait for the response if self.channel.publisher_confirms: response = self.channel.wait_for_confirmation() - if isinstance(response, specification.Basic.Ack): + if isinstance(response, commands.Basic.Ack): return True - elif isinstance(response, specification.Basic.Nack): + elif isinstance(response, commands.Basic.Nack): return False else: raise exceptions.UnexpectedResponseError(response) - def reject(self, requeue=False): + 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 bool requeue: Requeue the message + :param requeue: Requeue the message :raises: ActionException """ if not self.method: - raise exceptions.ActionException('Can not reject non-received ' - 'message') - basic_reject = specification.Basic.Reject(self.method.delivery_tag, - requeue=requeue) + 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): + 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): + def _add_timestamp(self) -> None: """Add the timestamp to the properties""" self.properties['timestamp'] = datetime.datetime.utcnow() @staticmethod - def _as_datetime(value): + def _as_datetime(value: typing.Union[datetime.datetime, + time.struct_time, int, float, + str, bytes]) \ + -> typing.Union[datetime.datetime, None]: """Return the passed in value as a ``datetime.datetime`` value. :param value: The value to convert or pass through - :type value: datetime.datetime - :type value: time.struct_time - :type value: int - :type value: float - :type value: str - :type value: bytes - :type value: unicode - :rtype: datetime.datetime :raises: TypeError """ @@ -341,74 +302,74 @@ def _as_datetime(value): if isinstance(value, time.struct_time): return datetime.datetime(*value[:6]) - if utils.is_string(value): + if isinstance(value, (bytes, str)): value = int(value) if isinstance(value, float) or isinstance(value, int): return datetime.datetime.fromtimestamp(value) - raise TypeError('Could not cast a %s value to a datetime.datetime' % - type(value)) + raise TypeError( + f'Could not cast a {value} value to a datetime.datetime') - def _auto_serialize(self, body_value): + def _auto_serialize(self, value: typing.Union[str, bytes, memoryview, + dict, list]) \ + -> typing.Union[str, bytes]: """Automatically serialize the body as JSON if it is a dict or list. - :param mixed body_value: The message body passed into the constructor - :return: bytes|str + :param value: The message body passed into the constructor """ - if isinstance(body_value, dict) or isinstance(body_value, list): + if isinstance(value, dict) or isinstance(value, list): self.properties['content_type'] = 'application/json' - return json.dumps(body_value, ensure_ascii=False) - return body_value + return json.dumps(value, ensure_ascii=False) + return value - def _coerce_properties(self): + def _coerce_properties(self) -> None: """Force properties to be set to the correct data type""" for key, value in self.properties.items(): - _type = specification.Basic.Properties.type(key) if self.properties[key] is None: continue - if _type == 'shortstr': - if not utils.is_string(value): + if commands.Basic.Properties.__annotations__[key] == 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 _type == 'octet' and not isinstance(value, int): + elif commands.Basic.Properties.__annotations__[key] == 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 _type == 'table' and not isinstance(value, dict): + elif commands.Basic.Properties.__annotations__[key] == \ + common.FieldTable and not isinstance(value, dict): LOGGER.warning('Resetting invalid value for %s to None', key) self.properties[key] = {} - if key == 'timestamp': + if commands.Basic.Properties.__annotations__[key] == \ + datetime.datetime: self.properties[key] = self._as_datetime(value) @property - def _invalid_properties(self): + def _invalid_properties(self) -> typing.List[str]: """Return a list of invalid properties that currently exist in the the properties that are set. - :rtype: list - """ - return [key for key in self.properties - if key not in specification.Basic.Properties.attributes()] + return [ + key for key in self.properties + if key not in commands.Basic.Properties.attributes() + ] @property - def _properties(self): + def _properties(self) -> commands.Basic.Properties: """Return a new Basic.Properties object representing the message properties. - :rtype: pamqp.specification.Basic.Properties - """ self._prune_invalid_properties() self._coerce_properties() - return specification.Basic.Properties(**self.properties) + return commands.Basic.Properties(**self.properties) - def _prune_invalid_properties(self): + 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) From 6ed64ecde029baa88de66d191865fa04831b2fad Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 16:47:15 -0400 Subject: [PATCH 09/59] Python3 Only, Add Type Annotations --- rabbitpy/heartbeat.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/rabbitpy/heartbeat.py b/rabbitpy/heartbeat.py index ff688ce..c0f27c9 100644 --- a/rabbitpy/heartbeat.py +++ b/rabbitpy/heartbeat.py @@ -5,43 +5,48 @@ """ import logging import threading +import typing + +from rabbitpy import channel0 as rabbitpy_channel0, io as rabbitpy_io LOGGER = logging.getLogger(__name__) -class Heartbeat(object): +class Heartbeat: """Send a heartbeat frame every interval if no data has been written. - :param rabbitpy.io.IO io: Used to get the # of bytes written each interval - :param rabbitpy.channel0.Channel channel0: The channel that the heartbeat - is sent over. + :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, channel0, interval): + 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 = None + self._timer: typing.Optional[threading.Timer] = None - def start(self): + 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 at least ' - 'every %.2f seconds', self._interval) + LOGGER.debug( + 'Heartbeat started, ensuring data is written every %.2f seconds', + self._interval) - def stop(self): + def stop(self) -> None: """Stop the heartbeat checker""" if self._timer: self._timer.cancel() self._timer = None - def _start_timer(self): + def _start_timer(self) -> None: """Create a new thread timer, destroying the last if it existed.""" if self._timer: del self._timer @@ -49,7 +54,7 @@ def _start_timer(self): self._timer.daemon = True self._timer.start() - def _maybe_send(self): + 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. From 528dcea56aecaf5c6c934021ea11a8d0dd67849f Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 16:48:48 -0400 Subject: [PATCH 10/59] yapf Reformatting --- rabbitpy/exceptions.py | 58 +++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/rabbitpy/exceptions.py b/rabbitpy/exceptions.py index c35d2e5..e571f18 100644 --- a/rabbitpy/exceptions.py +++ b/rabbitpy/exceptions.py @@ -6,12 +6,12 @@ class RabbitpyException(Exception): - """Base exception of all rabbitpy exceptions.""" + """Base exception for all rabbitpy exceptions.""" pass class AMQPException(RabbitpyException): - """Base exception of all AMQP exceptions.""" + """Base exception for all AMQP exceptions.""" pass @@ -22,12 +22,14 @@ class ActionException(RabbitpyException): sent by RabbitMQ via an AMQP Basic.Get or Basic.Consume. """ + def __str__(self): return self.args[0] class ChannelClosedException(RabbitpyException): """Raised when an action is attempted on a channel that is closed.""" + def __str__(self): return 'Can not perform RPC requests on a closed channel, you must ' \ 'create a new channel' @@ -39,8 +41,9 @@ class ConnectionException(RabbitpyException): authentication_failure_close feature added in RabbitMQ 3.2. """ + def __str__(self): - return 'Unable to connect to the remote server {0}'.format(self.args) + return f'Unable to connect to the remote server {self.args}' class ConnectionClosed(ConnectionException): @@ -48,6 +51,7 @@ class ConnectionClosed(ConnectionException): open. """ + def __str__(self): return 'The connection is closed' @@ -58,12 +62,14 @@ class ConnectionResetException(ConnectionException): missed heartbeat intervals if heartbeats are enabled. """ + def __str__(self): return 'Connection was reset at socket level' class RemoteCancellationException(RabbitpyException): """Raised if RabbitMQ cancels an active consumer""" + def __str__(self): return 'Remote server cancelled the active consumer' @@ -73,6 +79,7 @@ class RemoteClosedChannelException(RabbitpyException): Channel.Close RPC request does not have a mapped exception in Rabbitpy. """ + def __str__(self): return 'Channel {0} was closed by the remote server ' \ '({1}): {2}'.format(*self.args) @@ -83,6 +90,7 @@ class RemoteClosedException(RabbitpyException): Connection.Close RPC request does not have a mapped exception in Rabbitpy. """ + def __str__(self): return 'Connection was closed by the remote server ' \ '({0}): {1}'.format(*self.args) @@ -93,6 +101,7 @@ class MessageReturnedException(RabbitpyException): the Basic.Return RPC call. """ + def __str__(self): return 'Message was returned by RabbitMQ: ({0}) ' \ 'for exchange {1}'.format(*self.args) @@ -103,6 +112,7 @@ class NoActiveTransactionError(RabbitpyException): been initiated. """ + def __str__(self): return 'No active transaction for the request, channel closed' @@ -112,6 +122,7 @@ class NotConsumingError(RabbitpyException): actively consuming. """ + def __str__(self): return 'No active consumer to cancel' @@ -121,6 +132,7 @@ class NotSupportedError(RabbitpyException): server. """ + def __str__(self): return 'The selected feature "{0}" is not supported'.format(self.args) @@ -133,6 +145,7 @@ class TooManyChannelsError(RabbitpyException): this exception will be raised. """ + def __str__(self): return 'The maximum amount of negotiated channels has been reached' @@ -142,6 +155,7 @@ class UnexpectedResponseError(RabbitpyException): back is not recognized. """ + def __str__(self): return 'Received an expected response, expected {0}, ' \ 'received {1}'.format(*self.args) @@ -314,21 +328,23 @@ class AMQPInternalError(AMQPException): pass -AMQP = {311: AMQPContentTooLarge, - 312: AMQPNoRoute, - 313: AMQPNoConsumers, - 320: AMQPConnectionForced, - 402: AMQPInvalidPath, - 403: AMQPAccessRefused, - 404: AMQPNotFound, - 405: AMQPResourceLocked, - 406: AMQPPreconditionFailed, - 501: AMQPFrameError, - 502: AMQPSyntaxError, - 503: AMQPCommandInvalid, - 504: AMQPChannelError, - 505: AMQPUnexpectedFrame, - 506: AMQPResourceError, - 530: AMQPNotAllowed, - 540: AMQPNotImplemented, - 541: AMQPInternalError} +AMQP = { + 311: AMQPContentTooLarge, + 312: AMQPNoRoute, + 313: AMQPNoConsumers, + 320: AMQPConnectionForced, + 402: AMQPInvalidPath, + 403: AMQPAccessRefused, + 404: AMQPNotFound, + 405: AMQPResourceLocked, + 406: AMQPPreconditionFailed, + 501: AMQPFrameError, + 502: AMQPSyntaxError, + 503: AMQPCommandInvalid, + 504: AMQPChannelError, + 505: AMQPUnexpectedFrame, + 506: AMQPResourceError, + 530: AMQPNotAllowed, + 540: AMQPNotImplemented, + 541: AMQPInternalError +} From a569760a521944df3432d06567ac1f6627244a5a Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 16:49:11 -0400 Subject: [PATCH 11/59] yapf reformatting --- rabbitpy/events.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/rabbitpy/events.py b/rabbitpy/events.py index ac44491..a235ec4 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -18,15 +18,17 @@ SOCKET_CLOSED = 0x08 SOCKET_OPENED = 0x09 -DESCRIPTIONS = {0x01: 'Channel 0 Close Requested', - 0x02: 'Channel 0 Closed', - 0x03: 'Channel 0 Opened', - 0x04: 'Connection is blocked', - 0x05: 'Connection Event Occurred', - 0x06: 'Exception Raised', - 0x07: 'Socket Close Requested', - 0x08: 'Socket Closed', - 0x09: 'Socket Connected'} +DESCRIPTIONS = { + 0x01: 'Channel 0 Close Requested', + 0x02: 'Channel 0 Closed', + 0x03: 'Channel 0 Opened', + 0x04: 'Connection is blocked', + 0x05: 'Connection Event Occurred', + 0x06: 'Exception Raised', + 0x07: 'Socket Close Requested', + 0x08: 'Socket Closed', + 0x09: 'Socket Connected' +} def description(event_id: int) -> str: @@ -39,6 +41,7 @@ class Events: object for a common structure and method for raising and checking for them. """ + def __init__(self): """Create a new instance of Events""" self._events = self._create_event_objects() @@ -51,15 +54,11 @@ def _create_event_objects() -> typing.Dict[int, threading.Event]: """ events = dict() - for event in [CHANNEL0_CLOSE, - CHANNEL0_CLOSED, - CHANNEL0_OPENED, - CONNECTION_BLOCKED, - CONNECTION_EVENT, - EXCEPTION_RAISED, - SOCKET_CLOSE, - SOCKET_CLOSED, - SOCKET_OPENED]: + for event in [ + CHANNEL0_CLOSE, CHANNEL0_CLOSED, CHANNEL0_OPENED, + CONNECTION_BLOCKED, CONNECTION_EVENT, EXCEPTION_RAISED, + SOCKET_CLOSE, SOCKET_CLOSED, SOCKET_OPENED + ]: events[event] = threading.Event() return events @@ -125,6 +124,6 @@ def wait(self, event_id: int, timeout: float = 1) \ if event_id not in self._events: LOGGER.debug('Event does not exist: %s', description(event_id)) return None - LOGGER.debug('Waiting for %i seconds on event: %s', - timeout, description(event_id)) + LOGGER.debug('Waiting for %i seconds on event: %s', timeout, + description(event_id)) return self._events[event_id].wait(timeout) From ddcd6e54fb5db45c67be564543127b8c288a2453 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:04:32 -0400 Subject: [PATCH 12/59] Add Type Annotations, Drop Python2 Compatibility --- rabbitpy/connection.py | 349 +++++++++++++++++++---------------------- 1 file changed, 162 insertions(+), 187 deletions(-) diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index f8bbcc4..eaa290e 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -3,41 +3,37 @@ """ import logging -# pylint: disable=import-error -try: - from urllib import parse as urlparse -except ImportError: - import urlparse - +import queue try: import ssl except ImportError: ssl = None import threading import time +import types +import typing +from urllib import parse -from pamqp import specification as spec +from pamqp import base as pamqp_base, commands, constants, header -from rabbitpy import base -from rabbitpy import heartbeat -from rabbitpy import io -from rabbitpy import channel -from rabbitpy import channel0 -from rabbitpy import events -from rabbitpy import exceptions -from rabbitpy import message -from rabbitpy.utils import queue -from rabbitpy import utils +from rabbitpy import (base, channel as chan, channel0, events, exceptions, + heartbeat, io, message, utils) LOGGER = logging.getLogger(__name__) AMQP = 'amqp' AMQPS = 'amqps' +FrameExpectations = typing.Union[str, pamqp_base.Frame, + typing.List[typing.Union[str, + pamqp_base.Frame]]] + if ssl: - SSL_CERT_MAP = {'ignore': ssl.CERT_NONE, - 'optional': ssl.CERT_OPTIONAL, - 'required': ssl.CERT_REQUIRED} + SSL_CERT_MAP = { + 'ignore': ssl.CERT_NONE, + 'optional': ssl.CERT_OPTIONAL, + 'required': ssl.CERT_REQUIRED + } SSL_VERSION_MAP = dict() if hasattr(ssl, 'PROTOCOL_SSLv2'): SSL_VERSION_MAP['SSLv2'] = getattr(ssl, 'PROTOCOL_SSLv2') @@ -87,7 +83,7 @@ class Connection(base.StatefulObject): CANCEL_METHOD = ['Basic.Cancel'] DEFAULT_CHANNEL_MAX = 65535 DEFAULT_TIMEOUT = 3 - DEFAULT_HEARTBEAT_INTERVAL = 60.0 + DEFAULT_HEARTBEAT_INTERVAL = 60 DEFAULT_LOCALE = 'en_US' DEFAULT_URL = 'amqp://guest:guest@localhost:5672/%2F' DEFAULT_VHOST = '%2F' @@ -96,7 +92,7 @@ class Connection(base.StatefulObject): QUEUE_WAIT = 0.01 - def __init__(self, url=None): + def __init__(self, url: typing.Optional[str] = None): """Create a new instance of the Connection object""" super(Connection, self).__init__() @@ -119,27 +115,27 @@ def __init__(self, url=None): self._channel_lock = threading.Lock() # Attributes for core object threads - self._channel0 = None + self._channel0: typing.Optional[channel0.Channel0] = None self._channels = dict() - self._heartbeat = None - self._io = None + self._heartbeat: typing.Optional[heartbeat.Heartbeat] = None + self._io: typing.Optional[io.IO] = None # Used by Message for breaking up body frames - self._max_frame_size = None + self._max_frame_size: typing.Optional[int] = None # Connect to RabbitMQ self._connect() - def __enter__(self): + def __enter__(self) -> 'Connection': """For use as a context manager, return a handle to this object instance. - :rtype: Connection - """ return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + unused_exc_tb: typing.Optional[types.TracebackType]): """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -150,37 +146,31 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.close() @property - def args(self): - """Return the connection arguments. - - :rtype: dict - - """ + def args(self) -> dict: + """Return the connection arguments.""" return dict(self._args) @property - def blocked(self): + def blocked(self) -> bool: """Indicates if the connection is blocked from publishing by RabbitMQ. This flag indicates communication from RabbitMQ that the connection is blocked using the Connection.Blocked RPC notification from RabbitMQ that was added in RabbitMQ 3.2. - :rtype: bool - """ return self._events.is_set(events.CONNECTION_BLOCKED) - def channel(self, blocking_read=False): + def channel(self, blocking_read: bool = False) -> chan.Channel: """Create a new channel - If blocking_read is True, the cross-thread Queue.get use will use + If blocking_read is True, the cross-thread `Queue.get` use will use blocking operations that lower resource utilization and increase - throughput. However, due to how Python's blocking Queue.get is + throughput. However, due to how Python's blocking `Queue.get` is implemented, KeyboardInterrupt is not raised when CTRL-C is pressed. - :param bool blocking_read: Enable for higher throughput + :param blocking_read: Enable for higher throughput :raises: rabbitpy.exceptions.AMQPException :raises: rabbitpy.exceptions.RemoteClosedChannelException @@ -189,24 +179,25 @@ def channel(self, blocking_read=False): channel_id = self._get_next_channel_id() channel_frames = queue.Queue() self._channels[channel_id] = \ - channel.Channel(channel_id, - self.capabilities, - self._events, - self._exceptions, - channel_frames, - self._write_queue, - self._max_frame_size, - self._io.write_trigger, - self, - blocking_read) + chan.Channel(channel_id, + self.capabilities, + self._events, + self._exceptions, + channel_frames, + self._write_queue, + self._max_frame_size, + self._io.write_trigger, + self, + blocking_read) self._add_channel_to_io(self._channels[channel_id], channel_frames) self._channels[channel_id].open() return self._channels[channel_id] - def close(self): + def close(self) -> None: """Close the connection, including all open channels. :raises: rabbitpy.exceptions.ConnectionClosed + """ if not self.open and not self.opening: raise exceptions.ConnectionClosed() @@ -217,70 +208,66 @@ def close(self): self._set_state(self.CLOSED) @property - def capabilities(self): + def capabilities(self) -> dict: """Return the RabbitMQ Server capabilities from the connection negotiation process. - :rtype: dict - """ return self._channel0.properties.get('capabilities', dict()) @property - def server_properties(self): + def server_properties(self) -> dict: """Return the RabbitMQ Server properties from the connection negotiation process. - :rtype: dict - """ return self._channel0.properties - def _add_channel_to_io(self, channel_id, channel_queue): + def _add_channel_to_io(self, + channel: typing.Union[chan.Channel, + channel0.Channel0], + channel_queue: typing.Optional[queue.Queue]) \ + -> None: """Add a channel and queue to the IO object. - :param Queue.Queue channel_queue: Channel inbound msg queue - :param rabbitpy.base.AMQPChannel: The channel to add + :param channel: The channel object to add + :param channel_queue: Channel inbound msg queue """ - LOGGER.debug('Adding channel %s to io', int(channel_id)) - self._io.add_channel(channel_id, channel_queue) + LOGGER.debug('Adding channel %s to io', channel.id) + self._io.add_channel(channel, channel_queue) @property - def _api_credentials(self): - """Return the auth credentials as a tuple - - @rtype: tuple - - """ + def _api_credentials(self) -> typing.Tuple[str, str]: + """Return the auth credentials as a tuple""" return self._args['username'], self._args['password'] @property - def _channel0_closed(self): + def _channel0_closed(self) -> bool: """Returns a boolean indicating if the base connection channel (0) is closed. - :rtype: bool - """ return self._channel0.open and not \ self._events.is_set(events.CHANNEL0_CLOSED) - def _close_all_channels(self): + def _close_all_channels(self) -> None: """Close all open channels""" - for chan_id in [chan_id for chan_id in self._channels - if not self._channels[chan_id].closed]: + for chan_id in [ + chan_id for chan_id in self._channels + if not self._channels[chan_id].closed + ]: self._channels[chan_id].close() self._channel0.close() - def _close_channels(self): + def _close_channels(self) -> None: """Close all the channels that are currently open.""" for channel_id in self._channels: - if (self._channels[channel_id].open and - not self._channels[channel_id].closing): + if (self._channels[channel_id].open + and not self._channels[channel_id].closing): self._channels[channel_id].close() - def _connect(self): + def _connect(self) -> None: """Connect to the RabbitMQ Server""" self._set_state(self.OPENING) # Create and start the IO object that reads, writes & dispatches frames @@ -336,12 +323,8 @@ def _connect(self): self._heartbeat.start() self._set_state(self.OPEN) - def _create_channel0(self): - """Each connection should have a distinct channel0 - - :rtype: rabbitpy.channel0.Channel0 - - """ + def _create_channel0(self) -> channel0.Channel0: + """Each connection should have a distinct channel0""" return channel0.Channel0(connection_args=self._args, events_obj=self._events, exception_queue=self._exceptions, @@ -349,44 +332,39 @@ def _create_channel0(self): write_trigger=self._io.write_trigger, connection=self) - def _create_io_thread(self): - """Create the IO thread and the objects it uses for communication. - - :rtype: rabbitpy.io.IO - - """ + def _create_io_thread(self) -> io.IO: + """Create the IO thread and the objects it uses for communication.""" return io.IO(name='%s-io' % self._name, - kwargs={'events': self._events, - 'exceptions': self._exceptions, - 'connection_args': self._args, - 'write_queue': self._write_queue}) - - def _create_message(self, channel_id, method_frame, header_frame, body): + kwargs={ + 'events': self._events, + 'exceptions': self._exceptions, + 'connection_args': self._args, + 'write_queue': self._write_queue + }) + + def _create_message(self, channel_id: int, + method_frame: typing.Union[commands.Basic.Deliver, + commands.Basic.GetOk, + commands.Basic.Deliver], + header_frame: header.ContentHeader, + body: typing.Union[bytes, str]) -> message.Message: """Create a message instance with the channel it was received on and the dictionary of message parts. :param int channel_id: The channel id the message was sent on :param method_frame: The method frame value - :type method_frame: pamqp.specification.Frame :param header_frame: The header frame value - :type header_frame: pamqp.header.ContentHeader - :param str body: The message body - :rtype: rabbitpy.message.Message + :param body: The message body """ - msg = message.Message(self._channels[channel_id], - body, + msg = message.Message(self._channels[channel_id], body, header_frame.properties.to_dict()) msg.method = method_frame msg.name = method_frame.name return msg - def _get_next_channel_id(self): - """Return the next channel id - - :rtype: int - - """ + def _get_next_channel_id(self) -> int: + """Return the next channel id""" if not self._channels: return 1 if self._max_channel_id == self._channel0.maximum_channels: @@ -394,37 +372,33 @@ def _get_next_channel_id(self): return self._max_channel_id + 1 @property - def _max_channel_id(self): - """Return the maximum channel ID that is currently being used. - - :rtype: int - - """ + def _max_channel_id(self) -> int: + """Return the maximum channel ID that is currently being used.""" return max(list(self._channels.keys())) @staticmethod - def _normalize_expectations(channel_id, expectations): + def _normalize_expectations(channel_id: int, + expectations: FrameExpectations) \ + -> typing.List[str]: """Turn a class or list of classes into a list of class names. - :param int channel_id: The channel to normalize for + :param channel_id: The channel to normalize for :param expectations: List of classes or class name or class obj - :type expectations: list or str or pamqp.specification.Frame - :rtype: list """ if isinstance(expectations, list): output = list() for value in expectations: if isinstance(value, str): - output.append('%i:%s' % (channel_id, value)) + output.append(f'{channel_id}:{value}') else: - output.append('%i:%s' % (channel_id, value.name)) + output.append(f'{channel_id}:{value.name}') return output - elif utils.is_string(expectations): - return ['%i:%s' % (channel_id, expectations)] - return ['%i:%s' % (channel_id, expectations.name)] + elif isinstance(expectations, str): + return [f'{channel_id}:{expectations}'] + return [f'{channel_id}:{expectations.name}'] - def _process_url(self, url): + def _process_url(self, url: str) -> dict: """Parse the AMQP URL passed in and return the configuration information in a dictionary of values. @@ -469,7 +443,6 @@ def _process_url(self, url): - TLSv1 :param str url: The AMQP url passed in - :rtype: dict :raises: ValueError """ @@ -494,121 +467,123 @@ def _process_url(self, url): vhost = parsed.path[1:] or self.DEFAULT_VHOST # Parse the query string - qargs = utils.parse_qs(parsed.query) + query_args = parse.parse_qs(parsed.query) # Return the configuration dictionary to use when connecting return { 'host': parsed.hostname, 'port': parsed.port or scheme_port, - 'virtual_host': utils.unquote(vhost), - 'username': urlparse.unquote(parsed.username or self.GUEST), - 'password': urlparse.unquote(parsed.password or self.GUEST), - 'timeout': self._qargs_int('timeout', qargs, self.DEFAULT_TIMEOUT), - 'heartbeat': self._qargs_int('heartbeat', qargs, - self.DEFAULT_HEARTBEAT_INTERVAL), - 'frame_max': self._qargs_int('frame_max', qargs, - spec.FRAME_MAX_SIZE), - 'channel_max': self._qargs_int('channel_max', qargs, - self.DEFAULT_CHANNEL_MAX), - 'locale': self._qargs_value('locale', qargs), + 'virtual_host': parse.unquote(vhost), + 'username': parse.unquote(parsed.username or self.GUEST), + 'password': parse.unquote(parsed.password or self.GUEST), + 'timeout': self._query_args_int( + 'timeout', query_args, self.DEFAULT_TIMEOUT), + 'heartbeat': self._query_args_int( + 'heartbeat', query_args, self.DEFAULT_HEARTBEAT_INTERVAL), + 'frame_max': self._query_args_int( + 'frame_max', query_args, constants.FRAME_MAX_SIZE), + 'channel_max': self._query_args_int( + 'channel_max', query_args, self.DEFAULT_CHANNEL_MAX), + 'locale': self._query_args_value('locale', query_args), 'ssl': use_ssl, - 'cacertfile': self._qargs_mk_value(['cacertfile', 'ssl_cacert'], - qargs), - 'certfile': self._qargs_mk_value(['certfile', 'ssl_cert'], qargs), - 'keyfile': self._qargs_mk_value(['keyfile', 'ssl_key'], qargs), - 'verify': self._qargs_ssl_validation(qargs), - 'ssl_version': self._qargs_ssl_version(qargs)} + 'cacertfile': self._query_args_mk_value( + ['cacertfile', 'ssl_cacert'], query_args), + 'certfile': self._query_args_mk_value( + ['certfile', 'ssl_cert'], query_args), + 'keyfile': self._query_args_mk_value( + ['keyfile', 'ssl_key'], query_args), + 'verify': self._query_args_ssl_validation(query_args), + 'ssl_version': self._query_args_ssl_version(query_args) + } @staticmethod - def _qargs_int(key, values, default): + def _query_args_int(key: str, values: dict, default: int) -> int: """Return the query arg value as an integer for the specified key or return the specified default value. - :param str key: The key to return the value for - :param dict values: The query value dict returned by urlparse - :param int default: The default return value - :rtype: int + :param key: The key to return the value for + :param values: The query value dict returned by urlparse + :param default: The default return value """ return int(values.get(key, [default])[0]) @staticmethod - def _qargs_float(key, values, default): + def _query_args_float(key: str, values: dict, default: float) -> float: """Return the query arg value as a float for the specified key or return the specified default value. - :param str key: The key to return the value for - :param dict values: The query value dict returned by urlparse - :param float default: The default return value - :rtype: float + :param key: The key to return the value for + :param values: The query value dict returned by urlparse + :param default: The default return value """ return float(values.get(key, [default])[0]) - def _qargs_ssl_validation(self, values): + def _query_args_ssl_validation(self, values: dict) \ + -> typing.Union[int, None]: """Return the value mapped from the string value in the query string for the AMQP URL specifying which level of server certificate validation is required, if any. - :param dict values: The dict of query values from the AMQP URI - :rtype: int + :param values: The dict of query values from the AMQP URI """ - validation = self._qargs_mk_value(['verify', 'ssl_validation'], values) + validation = self._query_args_mk_value(['verify', 'ssl_validation'], + values) if not validation: return elif validation not in SSL_CERT_MAP: - raise ValueError( - 'Unsupported server cert validation option: %s', - validation) + raise ValueError('Unsupported server cert validation option: %s', + validation) return SSL_CERT_MAP[validation] - def _qargs_ssl_version(self, values): + def _query_args_ssl_version(self, values: dict) \ + -> typing.Union[int, None]: """Return the value mapped from the string value in the query string for the AMQP URL for SSL version. - :param dict values: The dict of query values from the AMQP URI - :rtype: int + :param values: The dict of query values from the AMQP URI """ - version = self._qargs_value('ssl_version', values) + version = self._query_args_value('ssl_version', values) if not version: return elif version not in SSL_VERSION_MAP: - raise ValueError('Unuspported SSL version: %s' % version) + raise ValueError(f'Unsupported SSL version: {version}') return SSL_VERSION_MAP[version] @staticmethod - def _qargs_value(key, values, default=None): + def _query_args_value(key: str, values: dict, + default: typing.Union[int, float, + str, None] = None) \ + -> typing.Union[int, float, str, None]: """Return the value from the query arguments for the specified key or the default value. - :param str key: The key to get the value for - :param dict values: The query value dict returned by urlparse - :return: mixed + :param key: The key to get the value for + :param values: The query value dict returned by urlparse """ return values.get(key, [default])[0] - def _qargs_mk_value(self, keys, values): + def _query_args_mk_value(self, keys: typing.List[str], values: dict) \ + -> typing.Union[int, float, str, None]: """Try and find the query string value where the value can be specified with different keys. - :param lists keys: The keys to check - :param dict values: The query value dict returned by urlparse - :return: mixed + :param keys: The keys to check + :param values: The query value dict returned by urlparse """ for key in keys: - value = self._qargs_value(key, values) + value = self._query_args_value(key, values) if value is not None: return value return None - def _shutdown_connection(self): - """Tell Channel0 and IO to stop if they are not stopped. - - """ + def _shutdown_connection(self) -> None: + """Tell Channel0 and IO to stop if they are not stopped.""" # Make sure the heartbeat is not running if self._heartbeat is not None: self._heartbeat.stop() @@ -622,8 +597,8 @@ def _shutdown_connection(self): # Break out of select waiting self._trigger_write() - if (self._events.is_set(events.SOCKET_OPENED) and - not self._events.is_set(events.SOCKET_CLOSED)): + if (self._events.is_set(events.SOCKET_OPENED) + and not self._events.is_set(events.SOCKET_CLOSED)): LOGGER.debug('Waiting on socket to close') self._events.wait(events.SOCKET_CLOSED, 0.1) @@ -634,17 +609,17 @@ def _shutdown_connection(self): while self._io.is_alive(): time.sleep(0.1) - def _trigger_write(self): + def _trigger_write(self) -> None: """Notifies the IO loop we need to write a frame by writing a byte to a local socket. """ utils.trigger_write(self._io.write_trigger) - def _validate_uri_scheme(self, scheme): - """Insure that the specified URI scheme is supported by rabbitpy + def _validate_uri_scheme(self, scheme: str) -> None: + """Ensure that the specified URI scheme is supported by rabbitpy - :param str scheme: The value to validate + :param scheme: The value to validate :raises: ValueError """ From e82bd26fde1f09f5ba316e19221b9d30beed046a Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:05:04 -0400 Subject: [PATCH 13/59] Drop Python3, Add Type Annotations, Refactor a bit --- rabbitpy/utils.py | 110 ++++++++++++++++------------------------------ 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/rabbitpy/utils.py b/rabbitpy/utils.py index 5187348..827d34e 100644 --- a/rabbitpy/utils.py +++ b/rabbitpy/utils.py @@ -1,103 +1,71 @@ -"""Utilities to make Python 3 support easier, providing wrapper methods which -can call the appropriate method for either Python 2 or Python 3 but creating -a single API point for rabbitpy to use. +""" +Utilities +========= """ import collections -# pylint: disable=unused-import,import-error -try: - import Queue as queue -except ImportError: - import queue +import logging import platform import socket -# pylint: disable=import-error -try: - from urllib import parse as _urlparse -except ImportError: - import urlparse as _urlparse - -from pamqp import PYTHON3 +import typing +from urllib import parse +LOGGER = logging.getLogger('rabbitpy') PYPY = platform.python_implementation() == 'PyPy' -Parsed = collections.namedtuple('Parsed', - 'scheme,netloc,path,params,query,fragment,' - 'username,password,hostname,port') +Parsed = collections.namedtuple( + 'Parsed', + 'scheme,netloc,path,params,query,fragment,username,password,hostname,port') -def maybe_utf8_encode(value): +def maybe_utf8_encode(value: typing.Union[str, bytes]) -> bytes: """Cross-python version method that will attempt to utf-8 encode a string. - :param mixed value: The value to maybe encode - :return: str + :param value: The value to maybe encode """ - - if PYTHON3: - if is_string(value) and not isinstance(value, bytes): - return bytes(value, 'utf-8') - return value - if isinstance(value, unicode): # pylint: disable=undefined-variable + try: return value.encode('utf-8') - return value - - -def parse_qs(query_string): - """Cross-python version method for parsing a query string. - - :param str query_string: The query string to parse - :return: tuple - """ - return _urlparse.parse_qs(query_string) + except AttributeError: + return value -def urlparse(url): +def urlparse(url: str) -> Parsed: """Parse a URL, returning a named tuple result. - :param str url: The URL to parse - :rtype: collections.namedtuple + :param url: The URL to parse """ value = 'http%s' % url[4:] if url[:4] == 'amqp' else url - parsed = _urlparse.urlparse(value) + 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 unquote(value): - """Cross-python version method for unquoting a URI value. + parsed.username, parsed.password, parsed.hostname, + parsed.port) - :param str value: The value to unquote - :rtype: str - - """ - return _urlparse.unquote(value) +def trigger_write(sock: socket.socket) -> None: + try: + sock.send(b'0') + except socket.error: + pass -def is_string(value): - """Check to see if the value is a string in Python 2 and 3. - :param bytes|str|unicode value: The value to check - :rtype: bool +class DebuggingOptimizationMixin: + """Micro-optimization to avoid logging overhead""" - """ - checks = [isinstance(value, bytes), isinstance(value, str)] - if not PYTHON3: - # pylint: disable=undefined-variable - checks.append(isinstance(value, unicode)) - return any(checks) + def __init__(self): + self._debugging: typing.Optional[bool] = 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. -def trigger_write(sock): - """Notifies the IO loop we need to write a frame by writing a byte - to a local socket. + :return: bool - """ - try: - sock.send(b'0') - except socket.error: - pass + """ + if self._debugging is None: + self._debugging = LOGGER.getEffectiveLevel() == logging.DEBUG + return self._debugging From e25213099ec78c1fb4e24776834ad9cbac6b3d23 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:24:53 -0400 Subject: [PATCH 14/59] Remove Python2, Add Type Annotations, Update to pamqp 3 --- rabbitpy/io.py | 333 ++++++++++++++++++++++++++----------------------- 1 file changed, 178 insertions(+), 155 deletions(-) diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 2470ca4..557c2fd 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -6,39 +6,43 @@ import collections import errno import logging +import queue import select import socket import ssl import threading +import typing +from socket import AddressFamily, SocketKind +from typing import List, Any, Tuple -from pamqp import frame -from pamqp import exceptions as pamqp_exceptions -from pamqp import specification +from pamqp import (base as pamqp_base, constants, + exceptions as pamqp_exceptions, frame) -from rabbitpy import base -from rabbitpy import events -from rabbitpy import exceptions +from rabbitpy import base, channel as chan, events, exceptions LOGGER = logging.getLogger(__name__) -MAX_READ = specification.FRAME_MAX_SIZE -MAX_WRITE = specification.FRAME_MAX_SIZE +MAX_READ = constants.FRAME_MAX_SIZE +MAX_WRITE = constants.FRAME_MAX_SIZE # Timeout in seconds POLL_TIMEOUT = 1.0 -class _SelectPoller(object): +class _SelectPoller: + def __init__(self, fd, write_trigger): self.read = [[fd, write_trigger], [], [fd], POLL_TIMEOUT] self.write = [[fd, write_trigger], [fd], [fd], POLL_TIMEOUT] - def poll(self, write_wanted): - """Invoke select.select, waiting for it to return with the per action + def poll(self, write_wanted: bool) \ + -> typing.Tuple[typing.List[int], + typing.List[int], + typing.List[int]]: + """Invoke `select.select`, waiting for it to return with the per action list of file descriptors that are returned to the IO Loop. - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) + :param write_wanted: Is there data pending to be written :return: (read, write, error) """ @@ -49,12 +53,12 @@ def poll(self, write_wanted): rlist, wlist, xlist = select.select(*self.read) except select.error: return [], [], [] - return ([sock.fileno() for sock in rlist], - [sock.fileno() for sock in wlist], + return ([sock.fileno() + for sock in rlist], [sock.fileno() for sock in wlist], [sock.fileno() for sock in xlist]) -class _KQueuePoller(object): +class _KQueuePoller: MAX_EVENTS = 1000 @@ -62,21 +66,24 @@ def __init__(self, fd, write_trigger): self._fd = fd self._write_trigger = write_trigger self._kqueue = select.kqueue() - self._kqueue.control([select.kevent(fd, select.KQ_FILTER_READ, - select.KQ_EV_ADD)], 0) - self._kqueue.control([select.kevent(write_trigger, - select.KQ_FILTER_READ, - select.KQ_EV_ADD)], 0) + self._kqueue.control( + [select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_ADD)], 0) + self._kqueue.control([ + select.kevent(write_trigger, select.KQ_FILTER_READ, + select.KQ_EV_ADD) + ], 0) self._write_in_last_poll = False - def poll(self, write_wanted): + def poll(self, write_wanted: bool) \ + -> typing.Tuple[typing.List[int], + typing.List[int], + typing.List[int]]: """Update the KQueue object with the desired actions to block on, - waiting until the KQueue.control method returns events and then returns - the list of actions containing file descriptors to act on for those - actions. + waiting until the `KQueue.control` method returns events and then + returns the list of actions containing file descriptors to act on for + those actions. - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) + :param write_wanted: Is there data pending to be written :return: (read, write, error) """ @@ -100,29 +107,38 @@ def poll(self, write_wanted): self._cleanup() return rlist, wlist, xlist - def _changelist(self, write_wanted): + def _changelist(self, write_wanted: bool) \ + -> typing.Union[typing.List[select.kevent], None]: if write_wanted and not self._write_in_last_poll: self._write_in_last_poll = True - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_ADD)] + return [ + select.kevent(self._fd, select.KQ_FILTER_WRITE, + select.KQ_EV_ADD) + ] elif self._write_in_last_poll and not write_wanted: self._write_in_last_poll = False - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE)] + return [ + select.kevent(self._fd, select.KQ_FILTER_WRITE, + select.KQ_EV_DELETE) + ] return None - def _cleanup(self): - self._kqueue.control([select.kevent(self._fd, select.KQ_FILTER_READ, - select.KQ_EV_DELETE)], 0) - self._kqueue.control([select.kevent(self._write_trigger, - select.KQ_FILTER_READ, - select.KQ_EV_DELETE)], 0) + def _cleanup(self) -> typing.Union[typing.List[select.kevent], None]: + self._kqueue.control([ + select.kevent(self._fd, select.KQ_FILTER_READ, select.KQ_EV_DELETE) + ], 0) + self._kqueue.control([ + select.kevent(self._write_trigger, select.KQ_FILTER_READ, + select.KQ_EV_DELETE) + ], 0) if self._write_in_last_poll: - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE)] + return [ + select.kevent(self._fd, select.KQ_FILTER_WRITE, + select.KQ_EV_DELETE) + ] -class _PollPoller(object): +class _PollPoller: # Register constants to prevent platform specific errors POLLIN = 1 @@ -132,14 +148,17 @@ class _PollPoller(object): READ = POLLIN | POLLERR WRITE = POLLIN | POLLOUT | POLLERR - def __init__(self, fd, write_trigger): + def __init__(self, fd: int, write_trigger: bool): self._fd = fd self._poll = select.poll() self._poll.register(fd, self.READ) self._poll.register(write_trigger, self.READ) self._write_in_last_poll = False - def poll(self, write_wanted): + def poll(self, write_wanted: bool) \ + -> typing.Tuple[typing.List[int], + typing.List[int], + typing.List[int]]: """Update the Poll object with the desired actions to block on, waiting until the poll returns events and then returns the list of actions containing file descriptors to act on for those actions. @@ -164,7 +183,7 @@ def poll(self, write_wanted): xlist.append(fileno) return rlist, wlist, xlist - def _update_poll(self, write_wanted): + def _update_poll(self, write_wanted: bool) -> None: if self._write_in_last_poll: if not write_wanted: self._write_in_last_poll = False @@ -174,13 +193,21 @@ def _update_poll(self, write_wanted): self._poll.modify(self._fd, self.WRITE) -class _IOLoop(object): +class _IOLoop: """Generic base IOLoop implementation that leverages different types of Polling (select, KQueue, poll). """ - def __init__(self, fd, error_callback, read_callback, write_callback, - write_queue, event_obj, write_trigger, exception_stack): + + def __init__(self, + fd: socket.socket, + error_callback: typing.Callable, + read_callback: typing.Callable, + write_callback: typing.Callable, + write_queue: queue.Queue, + event_obj: events.Events, + write_trigger: socket.socket, + exception_stack: queue.Queue): self._data = threading.local() self._data.fd = fd self._data.error_callback = error_callback @@ -192,11 +219,11 @@ def __init__(self, fd, error_callback, read_callback, write_callback, self._data.write_callback = write_callback self._data.write_queue = write_queue self._data.write_trigger = write_trigger - self._server_sock = None + self._server_sock: typing.Optional[socket.socket] = None self._exceptions = exception_stack self._poller = self._create_poller() - def run(self): + def run(self) -> None: """Run the IOLoop, blocking until the socket is closed or there is another exception. @@ -208,9 +235,9 @@ def run(self): except EnvironmentError as exception: if getattr(exception, 'errno') == errno.EINTR: continue - elif (isinstance(getattr(exception, 'args'), tuple) and - len(exception.args) == 2 and - exception.args[0] == errno.EINTR): + elif (isinstance(getattr(exception, 'args'), tuple) + and len(exception.args) == 2 + and exception.args[0] == errno.EINTR): continue if self._data.events.is_set(events.SOCKET_CLOSED): LOGGER.debug('Exiting due to closed socket') @@ -221,7 +248,7 @@ def run(self): break LOGGER.debug('Exiting IOLoop.run') - def stop(self): + def stop(self) -> None: """Stop the IOLoop.""" LOGGER.debug('Stopping IOLoop') self._data.running = False @@ -230,7 +257,8 @@ def stop(self): except socket.error: pass - def _create_poller(self): + def _create_poller(self) \ + -> typing.Union[_KQueuePoller, _PollPoller, _SelectPoller]: if hasattr(select, 'kqueue'): LOGGER.debug('Returning KQueuePoller') return _KQueuePoller(self._data.fd, self._data.write_trigger) @@ -241,7 +269,7 @@ def _create_poller(self): LOGGER.debug('Returning SelectPoller') return _SelectPoller(self._data.fd, self._data.write_trigger) - def _poll(self): + def _poll(self) -> None: # Poll select with the materialized lists if not self._data.running: LOGGER.debug('Exiting poll') @@ -272,7 +300,7 @@ def _poll(self): if wlist and self._data.write_buffer: self._write() - def _read(self): + def _read(self) -> None: if not self._data.running: LOGGER.debug('Skipping read, not running') return @@ -287,7 +315,7 @@ def _read(self): self._data.running = False self._data.error_callback(exception) - def _write(self): + def _write(self) -> None: if not self._data.running: LOGGER.debug('Skipping write frame, not running') return @@ -308,19 +336,19 @@ def _write(self): self._data.error_callback(error) else: self._data.write_callback(bytes_sent) - # If the entire frame could not be send, send the rest next time + # If the entire frame could not be sent, send the rest next time if bytes_sent < len(frame_data): self._data.write_buffer.appendleft(frame_data[bytes_sent:]) class IO(threading.Thread, base.StatefulObject): """IO is the primary IO thread that is responsible for communicating with - RabbitMQ at the socket level and adds demashalled frames to the appropriate + RabbitMQ at the socket level and adds demashaled frames to the appropriate thread-safe data structures. """ CONTENT_METHODS = ['Basic.Deliver', 'Basic.GetOk'] - READ_BUFFER_SIZE = specification.FRAME_MAX_SIZE + READ_BUFFER_SIZE = constants.FRAME_MAX_SIZE SSL_KWARGS = { 'keyfile': 'keyfile', 'certfile': 'certfile', @@ -330,11 +358,11 @@ class IO(threading.Thread, base.StatefulObject): } def __init__(self, - group=None, - target=None, - name=None, - args=(), - kwargs=None): + group: None = None, + target: typing.Optional[typing.Callable] = None, + name: typing.Optional[str] = None, + args: typing.Optional[tuple] = (), + kwargs: typing.Optional[dict] = None): if kwargs is None: kwargs = dict() super(IO, self).__init__(group, target, name, args, kwargs) @@ -353,40 +381,33 @@ def __init__(self, self._buffer = bytes() self._lock = threading.RLock() self._channels = dict() - self._remote_name = None - self._socket = None - self._state = None - self._loop = None + self._remote_name: typing.Optional[str] = None + self._socket: typing.Optional[socket.socket] = None + self._loop: typing.Optional[_IOLoop] = None - def add_channel(self, channel, write_queue): + def add_channel(self, + channel: chan.Channel, + write_queue: queue.Queue) -> None: """Add a channel to the channel queue dict for dispatching frames to the channel. - :param rabbitpy.channel.Channel channel: The channel to add - :param Queue.Queue write_queue: Queue for sending frames to the channel + :param channel: The channel to add + :param write_queue: Queue for sending frames to the channel """ self._channels[int(channel)] = channel, write_queue @property - def bytes_received(self): - """Return the number of bytes read/received from RabbitMQ - - :rtype: int - - """ + def bytes_received(self) -> int: + """Return the number of bytes read/received from RabbitMQ""" return self._bytes_read @property - def bytes_written(self): - """Return the number of bytes written to RabbitMQ - - :rtype: int - - """ + def bytes_written(self) -> int: + """Return the number of bytes written to RabbitMQ""" return self._bytes_written - def run(self): + def run(self) -> None: """The blocking method to execute the core IO object, that connects and then blocks on the IOLoop, exiting when the IOLoop stops. @@ -402,23 +423,22 @@ def run(self): # Create the remote name local_socket = self._socket.getsockname() peer_socket = self._socket.getpeername() - self._remote_name = '%s:%s -> %s:%s' % ( - local_socket[0], local_socket[1], peer_socket[0], peer_socket[1]) - self._loop = _IOLoop( - self._socket, self.on_error, self.on_read, self.on_write, - self._write_queue, self._events, self._write_listener, - self._exceptions) + self._remote_name = (f'{local_socket[0]}:{local_socket[1]} ' + f'-> {peer_socket[0]}:{peer_socket[1]}') + self._loop = _IOLoop(self._socket, self.on_error, self.on_read, + self.on_write, self._write_queue, self._events, + self._write_listener, self._exceptions) self._loop.run() if not self._exceptions.empty() and \ not self._events.is_set(events.EXCEPTION_RAISED): self._events.set(events.EXCEPTION_RAISED) LOGGER.debug('Exiting IO.run') - def on_error(self, exception): + def on_error(self, exception: socket.error) -> None: """Common functions when a socket error occurs. Make sure to set closed and add the exception, and note an exception event. - :param socket.error exception: The socket error + :param exception: The socket error """ LOGGER.critical('In on_error: %r', exception) @@ -431,11 +451,11 @@ def on_error(self, exception): self._exceptions.put(exceptions.ConnectionException(*args)) self._events.set(events.EXCEPTION_RAISED) - def on_read(self, data): + def on_read(self, data: bytes) -> None: """Append the data that is read to the buffer and try and parse frames out of it. - :param bytes data: The data that has been read in + :param data: The data that has been read in """ self._buffer += data @@ -452,8 +472,6 @@ def on_read(self, data): if self._buffer and value[0] is None: break - # LOGGER.debug('Received (%i) %r', value[0], value[1]) - # If it's channel 0, call the Channel0 directly if value[0] == 0: with self._lock: @@ -462,20 +480,20 @@ def on_read(self, data): self._add_frame_to_read_queue(value[0], value[1]) - def on_write(self, bytes_written): + def on_write(self, bytes_written: int) -> None: """Keep track of how many bytes have been written. - :param int bytes_written: How many bytes were written to the socket. + :param bytes_written: How many bytes were written to the socket. """ self._bytes_written += bytes_written - def stop(self): + def stop(self) -> None: """Stop the IO Layer due to exception or other problem.""" self._close() @property - def write_trigger(self): + def write_trigger(self) -> socket.socket: """Return the write trigger socket. :rtype: socket.socket @@ -483,19 +501,19 @@ def write_trigger(self): """ return self._write_trigger - def _add_frame_to_read_queue(self, channel_id, frame_value): + def _add_frame_to_read_queue(self, + channel_id: int, + value: pamqp_base.Frame): """Add the frame to the stack by creating the key value used in expectations and then add it to the list. :param int channel_id: The channel id the frame was received on - :param frame_value: The frame to add - :type frame_value: :class:`~pamqp.specification.Frame` + :param value: The frame to add """ - # LOGGER.debug('Adding %s to channel %s', frame_value.name, channel_id) - self._channels[channel_id][1].put(frame_value) + self._channels[channel_id][1].put(value) - def _close(self): + def _close(self) -> None: """Close the socket and set the proper event states""" self._events.clear(events.SOCKET_OPENED) self._set_state(self.CLOSING) @@ -513,26 +531,28 @@ def _close(self): self._set_state(self.CLOSED) @staticmethod - def _close_socket(sock): + def _close_socket(sock: socket.socket) -> None: try: sock.shutdown(socket.SHUT_RDWR) sock.close() except (OSError, socket.error): pass - def _connect_socket(self, sock, address): + def _connect_socket(self, + sock: socket.socket, + address: typing.Tuple[str, int]) -> None: """Connect the socket to the specified host and port, setting the timeout. - :param socket.socket sock: The socket to connect - :param (str, int) address: The address tuple to connect to + :param sock: The socket to connect + :param address: The address tuple to connect to """ LOGGER.debug('Connecting to %r', address) sock.settimeout(self._args['timeout']) sock.connect(address) - def _connect(self): + def _connect(self) -> None: """Connect to the RabbitMQ Server :raises: ConnectionException @@ -540,21 +560,21 @@ def _connect(self): """ self._set_state(self.OPENING) sock = None - # pylint: disable=unused-variable - for (address_family, socktype, - proto, cname, sockaddr) in self._get_addr_info(): + for (address_family, sock_type, + proto, _cname, sock_addr) in self._get_addr_info(): try: - sock = self._create_socket(address_family, socktype, proto) - self._connect_socket(sock, sockaddr) + sock = self._create_socket(address_family, sock_type, proto) + self._connect_socket(sock, sock_addr) break except socket.error as error: - LOGGER.debug('Error connecting to %r: %s', sockaddr, error) + LOGGER.debug('Error connecting to %r: %s', sock_addr, error) sock = None continue if not sock: - args = [self._args['host'], self._args['port'], - 'Could not connect'] + args = [ + self._args['host'], self._args['port'], 'Could not connect' + ] self._exceptions.put(exceptions.ConnectionException(*args)) self._events.set(events.EXCEPTION_RAISED) return @@ -563,16 +583,18 @@ def _connect(self): self._events.set(events.SOCKET_OPENED) self._set_state(self.OPEN) - def _create_socket(self, address_family, socktype, protocol): + def _create_socket(self, address_family: int, + sock_type: int, + protocol: int) \ + -> typing.Union[socket.socket, ssl.SSLSocket]: """Create the new socket, optionally with SSL support. - :param int address_family: The address family to use when creating - :param int socktype: The type of socket to create - :param int protocol: The protocol to use - :rtype: socket.socket or ssl.SSLSocket + :param address_family: The address family to use when creating + :param sock_type: The type of socket to create + :param protocol: The protocol to use """ - sock = socket.socket(address_family, socktype, protocol) + sock = socket.socket(address_family, sock_type, protocol) if self._args['ssl']: kwargs = {'sock': sock, 'server_side': False} for argv, key in self.SSL_KWARGS.items(): @@ -583,31 +605,35 @@ def _create_socket(self, address_family, socktype, protocol): return context.wrap_socket(**kwargs) return sock - def _disconnect_socket(self): + def _disconnect_socket(self) -> None: """Close the existing socket connection""" self._socket.close() - def _get_addr_info(self): + def _get_addr_info(self) \ + -> list[Any] | list[tuple[ + AddressFamily, SocketKind, int, str, tuple[str, int] | tuple[ + str, int, int, int]]]: family = socket.AF_UNSPEC if not socket.has_ipv6: family = socket.AF_INET try: - res = socket.getaddrinfo(self._args['host'], self._args['port'], - family, socket.SOCK_STREAM, 0) + res = socket.getaddrinfo( + self._args['host'], self._args['port'], family, + socket.SOCK_STREAM, 0) except socket.error as error: - LOGGER.error('Could not resolve %s: %s', self._args['host'], error, - exc_info=True) + LOGGER.error('Could not resolve %s: %s', + self._args['host'], error, exc_info=True) return [] return res @staticmethod - def _get_frame_from_str(value): + def _get_frame(value: bytes) \ + -> typing.Tuple[bytes, typing.Optional[int], + typing.Optional[pamqp_base.Frame]]: """Get the pamqp frame from the string value. - :param str value: The value to parse for an pamqp frame - :return (str, int, pamqp.specification.Frame): Remainder of value, - channel id and - frame value + :param value: The value to parse for a pamqp frame + :return Remainder of value, channel id, and frame value """ if not value: return value, None, None @@ -615,38 +641,35 @@ def _get_frame_from_str(value): byte_count, channel_id, frame_in = frame.unmarshal(value) except pamqp_exceptions.UnmarshalingException: return value, None, None - except specification.AMQPFrameError as error: + except pamqp_exceptions.AMQPFrameError as error: LOGGER.error('Failed to demarshal: %r', error, exc_info=True) LOGGER.debug(value) return value, None, None return value[byte_count:], channel_id, frame_in - def _read_frame(self): + def _read_frame(self) \ + -> typing.Tuple[int, typing.Optional[pamqp_base.Frame]]: """Read from the buffer and try and get the demarshaled frame. :rtype (int, pamqp.specification.Frame): The channel and frame """ - self._buffer, chan_id, value = self._get_frame_from_str(self._buffer) + self._buffer, chan_id, value = self._get_frame(self._buffer) return chan_id, value - def _remote_close_channel(self, channel_id, frame_value): + def _remote_close_channel(self, channel_id: int, + value: pamqp_base.Frame) -> None: """Invoke the on_channel_close code in the specified channel. This will block the IO loop unless the exception is caught. :param int channel_id: The channel to remote close - :param frame_value: The Channel.Close frame - :type frame_value: :class:`~pamqp.specification.Frame` + :param value: The Channel.Close frame """ - self._channels[channel_id][0].on_remote_close(frame_value) + self._channels[channel_id][0].on_remote_close(value) - def _socketpair(self): - """Return a socket pair regardless of platform. - - :rtype: (socket.socket, socket.socket) - - """ + def _socketpair(self) -> typing.Tuple[socket.socket, socket.socket]: + """Return a socket pair regardless of platform.""" try: server, client = socket.socketpair() except AttributeError: @@ -654,8 +677,8 @@ def _socketpair(self): LOGGER.debug('Falling back to emulated socketpair behavior') # Create the listening server socket & bind it to a random port - self._server_sock = socket.socket(socket.AF_INET, - socket.SOCK_STREAM) + self._server_sock = socket.socket( + socket.AF_INET, socket.SOCK_STREAM) self._server_sock.bind(('127.0.0.1', 0)) # Get the port for the notifying socket to connect to @@ -673,10 +696,10 @@ def connect(): # Have the listening server socket listen and accept the connect self._server_sock.listen(0) - # pylint: disable=unused-variable server, _unused = self._server_sock.accept() # Don't block on either socket - server.setblocking(0) - client.setblocking(0) + server.setblocking(False) + client.setblocking(False) + return server, client From 24aa7d369d356cce561fe02c5498f9978d218262 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:33:33 -0400 Subject: [PATCH 15/59] pamqp 3, add type annotations --- rabbitpy/exchange.py | 135 ++++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 65 deletions(-) diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index 5b0b3e6..d9640d8 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -9,9 +9,11 @@ """ import logging -from pamqp import specification +import typing -from rabbitpy import base +from pamqp import commands + +from rabbitpy import base, channel as chan LOGGER = logging.getLogger(__name__) @@ -21,12 +23,10 @@ class _Exchange(base.AMQPClass): declaration, binding and deletion. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param str exchange_type: The exchange type - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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 @@ -34,65 +34,70 @@ class _Exchange(base.AMQPClass): auto_delete = False type = 'direct' - def __init__(self, channel, name, durable=False, auto_delete=False, - arguments=None): + def __init__(self, + channel: chan.Channel, + name: str, + durable: bool = False, + auto_delete: bool = False, + arguments: typing.Optional[dict] = None): """Create a new instance of the exchange object.""" super(_Exchange, self).__init__(channel, name) self.durable = durable self.auto_delete = auto_delete self.arguments = arguments or dict() - def bind(self, source, routing_key=None): + def bind(self, + source: typing.Union[str, '_Exchange'], + routing_key: typing.Optional[str] = None) -> None: """Bind to another exchange with the routing key. :param source: The exchange to bind to - :type source: str or :py:class:`rabbitpy.Exchange` - :param str routing_key: The routing key to use + :param routing_key: The routing key to use """ if hasattr(source, 'name'): source = source.name - self._rpc(specification.Exchange.Bind(destination=self.name, - source=source, - routing_key=routing_key)) + self._rpc( + commands.Exchange.Bind( + destination=self.name, source=source, routing_key=routing_key)) - def declare(self, passive=False): + 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 bool passive: Do not actually create the exchange + :param passive: Do not actually create the exchange """ - self._rpc(specification.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=False): + 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(specification.Exchange.Delete(exchange=self.name, - if_unused=if_unused)) + self._rpc( + commands.Exchange.Delete( + exchange=self.name, if_unused=if_unused)) - def unbind(self, source, routing_key=None): + def unbind(self, source: typing.Union[str, '_Exchange'], + routing_key: typing.Optional[str] = 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 - :type source: str or :py:class:`rabbitpy.Exchange` - :param str routing_key: The routing key that binds them + :param routing_key: The routing key that binds them """ if hasattr(source, 'name'): source = source.name - self._rpc(specification.Exchange.Unbind(destination=self.name, - source=source, - routing_key=routing_key)) + self._rpc( + commands.Exchange.Unbind( + destination=self.name, source=source, routing_key=routing_key)) class Exchange(_Exchange): @@ -100,21 +105,25 @@ class Exchange(_Exchange): declaration, binding and deletion. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param str exchange_type: The exchange type - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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, name, exchange_type='direct', - durable=False, auto_delete=False, - arguments=None): + + def __init__(self, + channel: chan.Channel, + name: str, + exchange_type: str = 'direct', + durable: bool = False, + auto_delete: bool = False, + arguments: bool = None): """Create a new instance of the exchange object.""" self.type = exchange_type - super(Exchange, self).__init__(channel, name, durable, auto_delete, - arguments) + super(Exchange, self).__init__( + channel, name, durable, auto_delete, arguments) class DirectExchange(_Exchange): @@ -122,11 +131,10 @@ class DirectExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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' @@ -137,11 +145,10 @@ class FanoutExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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' @@ -152,11 +159,10 @@ class HeadersExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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' @@ -167,11 +173,10 @@ class TopicExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :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' From 50bb615c7f9a65376da8e685ba65ea0904b88759 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:35:53 -0400 Subject: [PATCH 16/59] Abstract exchange types out for type annotations --- rabbitpy/exchange.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index d9640d8..7299474 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -17,6 +17,10 @@ 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 @@ -47,7 +51,7 @@ def __init__(self, self.arguments = arguments or dict() def bind(self, - source: typing.Union[str, '_Exchange'], + source: ExchangeTypes, routing_key: typing.Optional[str] = None) -> None: """Bind to another exchange with the routing key. @@ -84,7 +88,8 @@ def delete(self, if_unused: bool = False) -> None: commands.Exchange.Delete( exchange=self.name, if_unused=if_unused)) - def unbind(self, source: typing.Union[str, '_Exchange'], + def unbind(self, + source: ExchangeTypes, routing_key: typing.Optional[str] = None) -> None: """Unbind the exchange from the source exchange with the routing key. If routing key is None, use the queue or exchange name. From 5e0ec085c18ba39a1c6d76d8acfb1cf79370893d Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 30 Jul 2024 17:38:37 -0400 Subject: [PATCH 17/59] Use the ExchangeTypes annotation --- rabbitpy/amqp_queue.py | 4 ++-- rabbitpy/message.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index 7632885..3163fde 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -158,7 +158,7 @@ def __setattr__(self, name: str, value: typing.Any) -> None: super(Queue, self).__setattr__(name, value) def bind(self, - source: typing.Union[str, exchange.Exchange], + source: exchange.ExchangeTypes, routing_key: typing.Optional[str] = None, arguments: typing.Optional[dict] = None) -> bool: """Bind the queue to the specified exchange or routing key. @@ -307,7 +307,7 @@ def stop_consuming(self) -> None: self.consuming = False def unbind(self, - source: typing.Union[str, exchange.Exchange], + source: exchange.ExchangeTypes, routing_key: typing.Optional[str] = None) -> None: """Unbind queue from the specified exchange where it is bound the routing key. If routing key is None, use the queue name. diff --git a/rabbitpy/message.py b/rabbitpy/message.py index 4d2b687..503b0e6 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -200,7 +200,7 @@ def pprint(self, properties: bool = False) -> None: # pragma: no cover pprint.pprint(self.body) def publish(self, - exchange: typing.Union[str, exc.Exchange], + exchange: exc.ExchangeTypes, routing_key: str = '', mandatory: bool = False, immediate: bool = False) -> typing.Union[bool, None]: From 6e00e1821e6e077a803a9dbcd2d9558f74498aa5 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 30 Dec 2024 14:23:42 -0500 Subject: [PATCH 18/59] Break out URL parsing --- rabbitpy/url_parser.py | 197 +++++++++++++++++++++++++++++++++++++++ tests/test_url_parser.py | 30 ++++++ 2 files changed, 227 insertions(+) create mode 100644 rabbitpy/url_parser.py create mode 100644 tests/test_url_parser.py diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py new file mode 100644 index 0000000..89a8ccb --- /dev/null +++ b/rabbitpy/url_parser.py @@ -0,0 +1,197 @@ +"""Parse URLS""" +import logging +import ssl +import typing +import urllib.parse + +from pamqp import constants + +LOGGER = logging.getLogger(__name__) + +AMQP = 'amqp' +AMQPS = 'amqps' +DEFAULT_CHANNEL_MAX = 65535 +DEFAULT_TIMEOUT = 3 +DEFAULT_HEARTBEAT_INTERVAL = 60 +DEFAULT_LOCALE = 'en_US' +DEFAULT_URL = 'amqp://guest:guest@localhost:5672/%2F' +DEFAULT_VHOST = '%2F' +GUEST = 'guest' +PORTS = {'amqp': 5672, 'amqps': 5671} +SSL_CERT_MAP = { + 'ignore': ssl.CERT_NONE, + 'optional': ssl.CERT_OPTIONAL, + 'required': ssl.CERT_REQUIRED +} + + +def parse(url: str = DEFAULT_URL) -> dict: + """Parse the AMQP URL passed in and return the configuration + information in a dictionary of values. + + The URL format is as follows: + + amqp[s]://username:password@host:port/virtual_host[?query string] + + Values in the URL such as the virtual_host should be URL encoded or + quoted just as a URL would be in a web browser. The default virtual + host / in RabbitMQ should be passed as %2F. + + Default values: + + - If port is omitted, port 5762 is used for AMQP and port 5671 is + used for AMQPS + - If username or password is omitted, the default value is guest + - If the virtual host is omitted, the default value of %2F is used + + Query string options: + + - heartbeat + - channel_max + - frame_max + - locale + - cacertfile - Path to CA certificate file + - certfile - Path to client certificate file + - keyfile - Path to client certificate key + - verify - Server certificate validation requirements (1) + + (1) Should be one of three values: + + - ignore - Ignore the cert if provided (default) + - optional - Cert is validated if provided + - required - Cert is required and validated + + :param url: The AMQP url passed in + :raises: ValueError + + """ + parsed = urllib.parse.urlparse(url) + + _validate_uri_scheme(parsed.scheme) + + # Toggle the SSL flag based upon the URL scheme and if SSL is enabled + use_ssl = True if parsed.scheme == AMQPS and ssl else False + + # Ensure that SSL is available if SSL is requested + if parsed.scheme == 'amqps' and not ssl: + LOGGER.warning('SSL requested but not available, disabling') + + # Figure out the port as specified by the scheme + scheme_port = PORTS[AMQPS] if parsed.scheme == AMQPS else PORTS[AMQP] + + # Set the vhost to be after the base slash if it was specified + vhost = DEFAULT_VHOST + if parsed.path: + vhost = parsed.path[1:] or DEFAULT_VHOST + + # Parse the query string + query_args = urllib.parse.parse_qs(parsed.query) + + # Return the configuration dictionary to use when connecting + return { + 'host': parsed.hostname, + 'port': parsed.port or scheme_port, + 'virtual_host': urllib.parse.unquote(vhost), + 'username': urllib.parse.unquote(parsed.username or GUEST), + 'password': urllib.parse.unquote(parsed.password or GUEST), + 'timeout': _query_args_int('timeout', query_args, DEFAULT_TIMEOUT), + 'heartbeat': _query_args_int( + 'heartbeat', query_args, DEFAULT_HEARTBEAT_INTERVAL), + 'frame_max': _query_args_int( + 'frame_max', query_args, constants.FRAME_MAX_SIZE), + 'channel_max': _query_args_int( + 'channel_max', query_args, DEFAULT_CHANNEL_MAX), + 'locale': _query_args_value('locale', query_args), + 'ssl': use_ssl, + 'ssl_options': { + 'cacertfile': _query_args_mk_value( + ['cacertfile', 'ssl_cacert'], query_args), + 'certfile': _query_args_mk_value( + ['certfile', 'ssl_cert'], query_args), + 'keyfile': _query_args_mk_value( + ['keyfile', 'ssl_key'], query_args), + 'verify': _query_args_ssl_validation(query_args) + } + } + + +def _query_args_int(key: str, values: dict, default: int) -> int: + """Return the query arg value as an integer for the specified key or + return the specified default value. + + :param key: The key to return the value for + :param values: The query value dict returned by urlparse + :param default: The default return value + + """ + return int(values.get(key, [default])[0]) + + +def _query_args_float(key: str, values: dict, default: float) -> float: + """Return the query arg value as a float for the specified key or + return the specified default value. + + :param key: The key to return the value for + :param values: The query value dict returned by urlparse + :param default: The default return value + + """ + return float(values.get(key, [default])[0]) + + +def _query_args_value( + key: str, values: dict, + default: typing.Union[int, float, str, None] = None) \ + -> typing.Union[int, float, str, None]: + """Return the value from the query arguments for the specified key + or the default value. + + :param key: The key to get the value for + :param values: The query value dict returned by urlparse + + """ + return values.get(key, [default])[0] + + +def _query_args_mk_value(keys: list[str], values: dict) \ + -> typing.Union[int, float, str, None]: + """Try and find the query string value where the value can be specified + with different keys. + + :param keys: The keys to check + :param values: The query value dict returned by urlparse + + """ + for key in keys: + value = _query_args_value(key, values) + if value is not None: + return value + return None + + +def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: + """Return the value mapped from the string value in the query string + for the AMQP URL specifying which level of server certificate + validation is required, if any. + + :param values: The dict of query values from the AMQP URI + + """ + validation = _query_args_mk_value(['verify', 'ssl_validation'], values) + if not validation: + return + elif validation not in SSL_CERT_MAP: + raise ValueError( + f'Unsupported server cert validation option: {validation}') + return SSL_CERT_MAP[validation] + + +def _validate_uri_scheme(scheme: str) -> None: + """Ensure that the specified URI scheme is supported by rabbitpy + + :param scheme: The value to validate + :raises: ValueError + + """ + if scheme not in list(PORTS.keys()): + raise ValueError(f'Unsupported URI scheme: {scheme}') diff --git a/tests/test_url_parser.py b/tests/test_url_parser.py new file mode 100644 index 0000000..57bba68 --- /dev/null +++ b/tests/test_url_parser.py @@ -0,0 +1,30 @@ +import unittest +import uuid + +from rabbitpy import url_parser + + +class URLParsingTestCase(unittest.TestCase): + + def test_default_url(self): + args = url_parser.parse() + self.assertEqual(args['host'], 'localhost') + self.assertEqual(args['port'], 5672) + self.assertEqual(args['virtual_host'], '/') + self.assertEqual(args['username'], 'guest') + self.assertEqual(args['password'], 'guest') + self.assertEqual(args['heartbeat'], 60) + self.assertEqual(args['ssl'], False) + self.assertEqual(args['timeout'], 3) + + def test_amqps(self): + pwd = str(uuid.uuid4()) + args = url_parser.parse(f'amqps://guest:{pwd}@localhost:5671/') + self.assertEqual(args['host'], 'localhost') + self.assertEqual(args['port'], 5671) + self.assertEqual(args['virtual_host'], '/') + self.assertEqual(args['username'], 'guest') + self.assertEqual(args['password'], pwd) + self.assertEqual(args['heartbeat'], 60) + self.assertEqual(args['ssl'], True) + self.assertEqual(args['timeout'], 3) From 52463ae809302cb4666d0fd4af603445b55d5562 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 30 Dec 2024 14:25:47 -0500 Subject: [PATCH 19/59] Clean up base project things --- README.rst | 13 ++++--------- bootstrap.sh | 3 ++- compose.yml | 2 +- pyproject.toml | 43 ++++++++++++++++++++++++++----------------- tox.ini | 9 --------- 5 files changed, 33 insertions(+), 37 deletions(-) delete mode 100644 tox.ini diff --git a/README.rst b/README.rst index ad9bee5..91c3379 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,9 @@ -rabbitpy - RabbitMQ simplified -============================== +rabbitpy +======== A pure python, thread-safe, minimalistic and Pythonic BSD Licensed -AMQP/RabbitMQ library that supports Python 2.7+ and Python 3.4+. +AMQP/RabbitMQ library that supports Python 3.9+. + rabbitpy aims to provide a simple and easy to use API for interfacing with RabbitMQ, minimizing the programming overhead often found in other libraries. @@ -18,12 +19,6 @@ your choice. I prefer pip: pip install rabbitpy -But there's always easy_install: - -.. code:: bash - - easy_install rabbitpy - Documentation ------------- diff --git a/bootstrap.sh b/bootstrap.sh index 13dad96..b0739b0 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -55,6 +55,7 @@ wait_for rabbitmq docker compose exec -T rabbitmq rabbitmqctl await_startup -cat > build/test-environment< build/test.env < Date: Mon, 30 Dec 2024 14:26:14 -0500 Subject: [PATCH 20/59] Ignore .coverage --- .gitignore | 51 +-------------------------------------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index ae508ec..7d3efac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,56 +1,7 @@ -# Byte-compiled / optimized / DLL files +.coverage __pycache__/ *.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python env/ -bin/ build/ -develop-eggs/ dist/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ *.egg-info/ -.installed.cfg -*.egg - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.cache -nosetests.xml -coverage.xml - -# Translations -*.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - -# Rope -.ropeproject - -# Django stuff: -*.log -*.pot - -# Sphinx documentation -docs/_build/ - -.idea -.vagrant From 555201ca12e8dd4e59b5d473022859cb436d91ce Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 30 Dec 2024 14:26:29 -0500 Subject: [PATCH 21/59] Remove .pylintrc --- .pylintrc | 379 ------------------------------------------------------ 1 file changed, 379 deletions(-) delete mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 29e0dde..0000000 --- a/.pylintrc +++ /dev/null @@ -1,379 +0,0 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Pickle collected data for later comparisons. -persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins=pylint.extensions.check_docs - -# Use multiple processes to speed up Pylint. -jobs=1 - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -#enable= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=coerce-builtin,dict-iter-method,old-octal-literal,old-ne-operator,reduce-builtin,xrange-builtin,dict-view-method,cmp-builtin,raising-string,buffer-builtin,input-builtin,unichr-builtin,coerce-method,backtick,print-statement,old-division,delslice-method,nonzero-method,long-builtin,using-cmp-argument,standarderror-builtin,setslice-method,indexing-exception,file-builtin,parameter-unpacking,suppressed-message,raw_input-builtin,long-suffix,useless-suppression,unpacking-in-except,getslice-method,zip-builtin-not-iterating,hex-method,import-star-module-level,unicode-builtin,map-builtin-not-iterating,apply-builtin,execfile-builtin,next-method-called,cmp-method,metaclass-assignment,reload-builtin,round-builtin,old-raise-syntax,filter-builtin-not-iterating,intern-builtin,no-absolute-import,oct-method,basestring-builtin,range-builtin-not-iterating - - -[REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages -reports=yes - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - - -[BASIC] - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - - -[ELIF] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=100 - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - -# Maximum number of lines in a module -max-module-lines=1000 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[TYPECHECK] - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). This supports can work -# with qualified names. -ignored-classes= - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_$|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=10 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of statements in function / method body -max-statements=50 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of attributes for a class (see R0902). -max-attributes=15 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - - -[IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception From c9269e8da7b64c4ddff669bdb7ed7cc726519816 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 30 Dec 2024 14:32:01 -0500 Subject: [PATCH 22/59] Refactor the Events class and its tests Refactored the `Events` class for improved readability and adherence to Python best practices, including updated docstrings and minor formatting adjustments. Update tests removing conditions for Python 2 support. --- rabbitpy/events.py | 32 ++++++++++------- tests/test_events.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 tests/test_events.py diff --git a/rabbitpy/events.py b/rabbitpy/events.py index a235ec4..246d62e 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -1,7 +1,8 @@ """ -Common rabbitpy events +A high-level wrapper for using threading.Event objects to signal state changes """ + import logging import threading import typing @@ -27,7 +28,7 @@ 0x06: 'Exception Raised', 0x07: 'Socket Close Requested', 0x08: 'Socket Closed', - 0x09: 'Socket Connected' + 0x09: 'Socket Connected', } @@ -47,18 +48,22 @@ def __init__(self): self._events = self._create_event_objects() @staticmethod - def _create_event_objects() -> typing.Dict[int, threading.Event]: + def _create_event_objects() -> dict[int, threading.Event]: """Events are used like signals across threads for communicating state changes, used by the various threaded objects to communicate with each other when an action needs to be taken. """ - events = dict() - for event in [ - CHANNEL0_CLOSE, CHANNEL0_CLOSED, CHANNEL0_OPENED, - CONNECTION_BLOCKED, CONNECTION_EVENT, EXCEPTION_RAISED, - SOCKET_CLOSE, SOCKET_CLOSED, SOCKET_OPENED - ]: + events = {} + for event in [CHANNEL0_CLOSE, + CHANNEL0_CLOSED, + CHANNEL0_OPENED, + CONNECTION_BLOCKED, + CONNECTION_EVENT, + EXCEPTION_RAISED, + SOCKET_CLOSE, + SOCKET_CLOSED, + SOCKET_OPENED]: events[event] = threading.Event() return events @@ -110,8 +115,9 @@ def set(self, event_id: int) -> typing.Union[bool, None]: self._events[event_id].set() return True - def wait(self, event_id: int, timeout: float = 1) \ - -> typing.Union[bool, None]: + def wait(self, + event_id: int, + timeout: float = 1) -> typing.Union[bool, None]: """Wait for an event to be set for up to `timeout` seconds. If `timeout` is None, block until the event is set. If the event is invalid, None will be returned, otherwise False is used to indicate @@ -124,6 +130,6 @@ def wait(self, event_id: int, timeout: float = 1) \ if event_id not in self._events: LOGGER.debug('Event does not exist: %s', description(event_id)) return None - LOGGER.debug('Waiting for %i seconds on event: %s', timeout, - description(event_id)) + LOGGER.debug('Waiting for %i seconds on event: %s', + timeout, description(event_id)) return self._events[event_id].wait(timeout) diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..af84464 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,81 @@ +""" +Test the rabbitpy events class + +""" +import threading +import unittest +from unittest import mock + +from rabbitpy import events + + +class BaseEventsTest(unittest.TestCase): + + def setUp(self): + self._events = events.Events() + + def tearDown(self): + del self._events + + +class EventClearTests(BaseEventsTest): + + def test_invalid_event(self): + self.assertIsNone(self._events.clear(0)) + + def test_valid_clear_returns_true(self): + self._events.set(events.CHANNEL0_OPENED) + self.assertTrue(self._events.clear(events.CHANNEL0_OPENED)) + + def test_unset_event_returns_false(self): + self.assertFalse(self._events.clear(events.CHANNEL0_OPENED)) + + +class EventInitTests(BaseEventsTest): + + def test_all_events_created(self): + for event in events.DESCRIPTIONS.keys(): + self.assertIsInstance(self._events._events[event], threading.Event, + type(self._events._events[event])) + + +class EventIsSetTests(BaseEventsTest): + + def test_invalid_event(self): + self.assertIsNone(self._events.is_set(0)) + + def test_valid_is_set_returns_true(self): + self._events.set(events.CHANNEL0_CLOSED) + self.assertTrue(self._events.is_set(events.CHANNEL0_CLOSED)) + + def test_unset_event_returns_false(self): + self.assertFalse(self._events.is_set(events.CHANNEL0_OPENED)) + + +class EventSetTests(BaseEventsTest): + + def test_invalid_event(self): + self.assertIsNone(self._events.set(0)) + + def test_valid_set_returns_true(self): + self.assertTrue(self._events.set(events.CHANNEL0_CLOSED)) + + def test_already_set_event_returns_false(self): + self._events.set(events.CHANNEL0_OPENED) + self.assertFalse(self._events.set(events.CHANNEL0_OPENED)) + + +class EventWaitTests(BaseEventsTest): + + def test_invalid_event(self): + self.assertIsNone(self._events.wait(0)) + + def test_blocking_wait_returns_true(self): + with mock.patch.object(threading.Event, 'wait') as wait: + wait.return_value = True + self.assertTrue(self._events.wait(events.CHANNEL0_CLOSED)) + + def test_blocking_wait_returns_false(self): + with mock.patch.object(threading.Event, 'wait') as wait: + wait.return_value = False + self.assertFalse(self._events.wait(events.CHANNEL0_CLOSED, 1)) From 9a85d56666db4f7f4cb88b98bfa9dba64b4bad86 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 30 Dec 2024 14:32:38 -0500 Subject: [PATCH 23/59] "Remove obsolete test environment setup from __init__.py" The setup_module function and related imports were removed as they are no longer required for test environment configuration. This simplifies the codebase and eliminates unnecessary warning filters and file handling logic. --- tests/__init__.py | 17 ----------------- tests/integration/__init__.py | 0 2 files changed, 17 deletions(-) create mode 100644 tests/integration/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py index 1b6cbb4..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,17 +0,0 @@ -# coding=utf-8 -import os -import warnings - -warnings.simplefilter('ignore', UserWarning) - - -def setup_module(): - try: - with open('build/test-environment') as f: - for line in f: - if line.startswith('export '): - line = line[7:] - name, _, value = line.strip().partition('=') - os.environ[name] = value - except IOError: - pass \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 From a5030d0de428c8f52dee6f8a9ff3f8eafae17c6c Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Mon, 6 Jan 2025 17:44:36 -0500 Subject: [PATCH 24/59] Add asynchronous IO implementation and test cases Refactors the IO module to replace the legacy polling-based IOLoop with an asyncio-based architecture. Introduces tests for the new async IO functionality, ensuring robust handling of socket communication and frame processing. --- rabbitpy/io.py | 821 +++++++++++++---------------------------------- tests/test_io.py | 246 ++++++++++++++ 2 files changed, 465 insertions(+), 602 deletions(-) create mode 100644 tests/test_io.py diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 557c2fd..655c777 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -1,558 +1,150 @@ -""" -Core Threaded IO implementation used to communicate with RabbitMQ at the socket -level. - -""" +import asyncio import collections -import errno import logging import queue -import select import socket import ssl import threading import typing -from socket import AddressFamily, SocketKind -from typing import List, Any, Tuple -from pamqp import (base as pamqp_base, constants, - exceptions as pamqp_exceptions, frame) +import pamqp.exceptions +from pamqp import base, frame -from rabbitpy import base, channel as chan, events, exceptions +import rabbitpy.events +import rabbitpy.exceptions +from rabbitpy import state LOGGER = logging.getLogger(__name__) +MAX_BUFFER_PROCESSING_RETRIES = 5 -MAX_READ = constants.FRAME_MAX_SIZE -MAX_WRITE = constants.FRAME_MAX_SIZE - -# Timeout in seconds -POLL_TIMEOUT = 1.0 - - -class _SelectPoller: - - def __init__(self, fd, write_trigger): - self.read = [[fd, write_trigger], [], [fd], POLL_TIMEOUT] - self.write = [[fd, write_trigger], [fd], [fd], POLL_TIMEOUT] - - def poll(self, write_wanted: bool) \ - -> typing.Tuple[typing.List[int], - typing.List[int], - typing.List[int]]: - """Invoke `select.select`, waiting for it to return with the per action - list of file descriptors that are returned to the IO Loop. - - :param write_wanted: Is there data pending to be written - :return: (read, write, error) - - """ - try: - if write_wanted: - rlist, wlist, xlist = select.select(*self.write) - else: - rlist, wlist, xlist = select.select(*self.read) - except select.error: - return [], [], [] - return ([sock.fileno() - for sock in rlist], [sock.fileno() for sock in wlist], - [sock.fileno() for sock in xlist]) - - -class _KQueuePoller: - - MAX_EVENTS = 1000 - - def __init__(self, fd, write_trigger): - self._fd = fd - self._write_trigger = write_trigger - self._kqueue = select.kqueue() - self._kqueue.control( - [select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_ADD)], 0) - self._kqueue.control([ - select.kevent(write_trigger, select.KQ_FILTER_READ, - select.KQ_EV_ADD) - ], 0) - self._write_in_last_poll = False - - def poll(self, write_wanted: bool) \ - -> typing.Tuple[typing.List[int], - typing.List[int], - typing.List[int]]: - """Update the KQueue object with the desired actions to block on, - waiting until the `KQueue.control` method returns events and then - returns the list of actions containing file descriptors to act on for - those actions. - - :param write_wanted: Is there data pending to be written - :return: (read, write, error) - - """ - rlist, wlist, xlist = [], [], [] - try: - kq_events = self._kqueue.control(self._changelist(write_wanted), - self.MAX_EVENTS, POLL_TIMEOUT) - except select.error as error: - LOGGER.debug('kqueue.control error: %s', error) - return [], [], [] - for event in kq_events: - if event.filter == select.KQ_FILTER_READ: - rlist.append(event.ident) - elif event.filter == select.KQ_FILTER_WRITE: - wlist.append(event.ident) - if event.flags & select.KQ_EV_ERROR: - xlist.append(event.ident) - if event.flags & select.KQ_EV_EOF == select.KQ_EV_EOF: - xlist.append(event.data) - if xlist: - self._cleanup() - return rlist, wlist, xlist - - def _changelist(self, write_wanted: bool) \ - -> typing.Union[typing.List[select.kevent], None]: - if write_wanted and not self._write_in_last_poll: - self._write_in_last_poll = True - return [ - select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_ADD) - ] - elif self._write_in_last_poll and not write_wanted: - self._write_in_last_poll = False - return [ - select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE) - ] - return None - - def _cleanup(self) -> typing.Union[typing.List[select.kevent], None]: - self._kqueue.control([ - select.kevent(self._fd, select.KQ_FILTER_READ, select.KQ_EV_DELETE) - ], 0) - self._kqueue.control([ - select.kevent(self._write_trigger, select.KQ_FILTER_READ, - select.KQ_EV_DELETE) - ], 0) - if self._write_in_last_poll: - return [ - select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE) - ] - - -class _PollPoller: - - # Register constants to prevent platform specific errors - POLLIN = 1 - POLLOUT = 4 - POLLERR = 8 - - READ = POLLIN | POLLERR - WRITE = POLLIN | POLLOUT | POLLERR - - def __init__(self, fd: int, write_trigger: bool): - self._fd = fd - self._poll = select.poll() - self._poll.register(fd, self.READ) - self._poll.register(write_trigger, self.READ) - self._write_in_last_poll = False - - def poll(self, write_wanted: bool) \ - -> typing.Tuple[typing.List[int], - typing.List[int], - typing.List[int]]: - """Update the Poll object with the desired actions to block on, waiting - until the poll returns events and then returns the list of actions - containing file descriptors to act on for those actions. - - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) - :return: (read, write, error) - - """ - self._update_poll(write_wanted) - rlist, wlist, xlist = [], [], [] - try: - poll_events = self._poll.poll(POLL_TIMEOUT * 1000) - except select.error: - return [], [], [] - for fileno, event in poll_events: - if event & self.POLLIN: - rlist.append(fileno) - if event & self.POLLOUT: - wlist.append(fileno) - if event & self.POLLERR: - xlist.append(fileno) - return rlist, wlist, xlist - - def _update_poll(self, write_wanted: bool) -> None: - if self._write_in_last_poll: - if not write_wanted: - self._write_in_last_poll = False - self._poll.modify(self._fd, self.READ) - else: - self._write_in_last_poll = True - self._poll.modify(self._fd, self.WRITE) +AddrInfo = typing.Union[ + list[typing.Any], + list[tuple[socket.AddressFamily, socket.SocketKind, int, str, + tuple[str, int], tuple[str, int, int, int]]] +] -class _IOLoop: - """Generic base IOLoop implementation that leverages different types of - Polling (select, KQueue, poll). - - """ +class IO(threading.Thread, state.StatefulBase): def __init__(self, - fd: socket.socket, - error_callback: typing.Callable, - read_callback: typing.Callable, - write_callback: typing.Callable, - write_queue: queue.Queue, - event_obj: events.Events, - write_trigger: socket.socket, - exception_stack: queue.Queue): - self._data = threading.local() - self._data.fd = fd - self._data.error_callback = error_callback - self._data.read_callback = read_callback - self._data.running = False - self._data.ssl = hasattr(fd, 'read') - self._data.events = event_obj - self._data.write_buffer = collections.deque() - self._data.write_callback = write_callback - self._data.write_queue = write_queue - self._data.write_trigger = write_trigger - self._server_sock: typing.Optional[socket.socket] = None - self._exceptions = exception_stack - self._poller = self._create_poller() - - def run(self) -> None: - """Run the IOLoop, blocking until the socket is closed or there is - another exception. - - """ - self._data.running = True - while self._data.running: - try: - self._poll() - except EnvironmentError as exception: - if getattr(exception, 'errno') == errno.EINTR: - continue - elif (isinstance(getattr(exception, 'args'), tuple) - and len(exception.args) == 2 - and exception.args[0] == errno.EINTR): - continue - if self._data.events.is_set(events.SOCKET_CLOSED): - LOGGER.debug('Exiting due to closed socket') - break - elif self._data.events.is_set(events.SOCKET_CLOSE): - LOGGER.debug('Exiting due to closing socket') - self._exceptions.put(exceptions.ConnectionResetException()) - break - LOGGER.debug('Exiting IOLoop.run') - - def stop(self) -> None: - """Stop the IOLoop.""" - LOGGER.debug('Stopping IOLoop') - self._data.running = False - try: - self._data.write_trigger.close() - except socket.error: - pass - - def _create_poller(self) \ - -> typing.Union[_KQueuePoller, _PollPoller, _SelectPoller]: - if hasattr(select, 'kqueue'): - LOGGER.debug('Returning KQueuePoller') - return _KQueuePoller(self._data.fd, self._data.write_trigger) - elif hasattr(select, 'poll'): - LOGGER.debug('Returning PollPoller') - return _PollPoller(self._data.fd, self._data.write_trigger) - else: - LOGGER.debug('Returning SelectPoller') - return _SelectPoller(self._data.fd, self._data.write_trigger) - - def _poll(self) -> None: - # Poll select with the materialized lists - if not self._data.running: - LOGGER.debug('Exiting poll') - - # Build the outbound write buffer of marshalled frames - while not self._data.write_queue.empty(): - data = self._data.write_queue.get(False) - self._data.write_buffer.append(frame.marshal(data[1], data[0])) - - # Poll the poller, passing in a bool if there is data to write - rlist, wlist, xlist = self._poller.poll(bool(self._data.write_buffer)) - - if xlist: - LOGGER.debug('Poll errors: %r', xlist) - self._data.events.set(events.SOCKET_CLOSE) - self._data.error_callback('Connection reset') - return - - # Clear out the trigger socket - if self._data.write_trigger.fileno() in rlist: - self._data.write_trigger.recv(1024) - - # Read if the data socket is in the read list - if self._data.fd.fileno() in rlist: - self._read() - - # Write if the data socket is writable - if wlist and self._data.write_buffer: - self._write() - - def _read(self) -> None: - if not self._data.running: - LOGGER.debug('Skipping read, not running') - return - try: - if self._data.ssl: - self._data.read_callback(self._data.fd.read(MAX_READ)) - else: - self._data.read_callback(self._data.fd.recv(MAX_READ)) - except socket.timeout: - LOGGER.warning('Timed out reading from socket') - except socket.error as exception: - self._data.running = False - self._data.error_callback(exception) - - def _write(self) -> None: - if not self._data.running: - LOGGER.debug('Skipping write frame, not running') - return - - frame_data = self._data.write_buffer.popleft() - try: - bytes_sent = self._data.fd.send(frame_data) - except socket.timeout: - LOGGER.warning('Timed out writing %i bytes to socket', - len(frame_data)) - self._data.write_buffer.appendleft(frame_data) - except socket.error as error: - if error.errno == 35: - LOGGER.debug('socket resource temp unavailable') - self._data.write_buffer.appendleft(frame_data) - else: - self._data.running = False - self._data.error_callback(error) - else: - self._data.write_callback(bytes_sent) - # If the entire frame could not be sent, send the rest next time - if bytes_sent < len(frame_data): - self._data.write_buffer.appendleft(frame_data[bytes_sent:]) - - -class IO(threading.Thread, base.StatefulObject): - """IO is the primary IO thread that is responsible for communicating with - RabbitMQ at the socket level and adds demashaled frames to the appropriate - thread-safe data structures. - - """ - CONTENT_METHODS = ['Basic.Deliver', 'Basic.GetOk'] - READ_BUFFER_SIZE = constants.FRAME_MAX_SIZE - SSL_KWARGS = { - 'keyfile': 'keyfile', - 'certfile': 'certfile', - 'cert_reqs': 'verify', - 'ssl_version': 'ssl_version', - 'ca_certs': 'cacertfile' - } - - def __init__(self, - group: None = None, - target: typing.Optional[typing.Callable] = None, - name: typing.Optional[str] = None, - args: typing.Optional[tuple] = (), - kwargs: typing.Optional[dict] = None): - if kwargs is None: - kwargs = dict() - super(IO, self).__init__(group, target, name, args, kwargs) - - self._args = kwargs['connection_args'] - self._events = kwargs['events'] - self._exceptions = kwargs['exceptions'] - self._write_queue = kwargs['write_queue'] - - # A socket to trigger write interrupts with - self._write_listener, self._write_trigger = self._socketpair() - + host: str, + port: int, + use_ssl: bool, + ssl_options: dict, + events: rabbitpy.events.Events, + exceptions: queue.Queue, + timeout: float = 0.01): + super().__init__(group=None, + target=None, + name='IO', + args=(), + kwargs={}, + daemon=True) + self._events = events + self._exceptions = exceptions + self._host = host + self._port = port + self._ssl_options = ssl_options + self._timeout = timeout + self._use_ssl = use_ssl + + self._buffer = b'' + self._lock = threading.Lock() self._bytes_read = 0 self._bytes_written = 0 - - self._buffer = bytes() - self._lock = threading.RLock() - self._channels = dict() + self._channels: collections.defaultdict[int, queue.Queue] = ( + collections.defaultdict(queue.Queue)) + self._ioloop: typing.Optional[asyncio.AbstractEventLoop] = None self._remote_name: typing.Optional[str] = None - self._socket: typing.Optional[socket.socket] = None - self._loop: typing.Optional[_IOLoop] = None + self._running = threading.Event() + self._write_buffer = collections.deque() - def add_channel(self, - channel: chan.Channel, - write_queue: queue.Queue) -> None: + self._write_queue: queue.Queue = queue.Queue() + + def run(self): + asyncio.run(self._run()) + try: + self._ioloop.run_forever() + finally: + self._ioloop.close() + + async def _run(self): + self._ioloop = asyncio.get_running_loop() + await self._connect() + + self._ioloop.add_reader(self._socket, self._on_read_ready) + self._ioloop.add_writer(self._socket, self._on_write_ready) + + local_sock = self._socket.getsockname() + peer_sock = self._socket.getpeername() + self._remote_name = \ + f'{local_sock[0]}:{local_sock[1]} -> {peer_sock[0]}:{peer_sock[1]}' + + if self.is_open: + LOGGER.info('Connected to %s:%i', self._host, self._port) + self._running.set() + while self._running.is_set(): + await asyncio.sleep(10) + LOGGER.info('Disconnected from %s:%i', self._host, self._port) + self.stop() + + def add_channel(self, channel: int, read_queue: queue.Queue) -> None: """Add a channel to the channel queue dict for dispatching frames to the channel. :param channel: The channel to add - :param write_queue: Queue for sending frames to the channel + :param read_queue: Queue for sending frames to the channel """ - self._channels[int(channel)] = channel, write_queue + self._channels[int(channel)] = read_queue @property def bytes_received(self) -> int: """Return the number of bytes read/received from RabbitMQ""" - return self._bytes_read + with self._lock: + return self._bytes_read @property def bytes_written(self) -> int: """Return the number of bytes written to RabbitMQ""" - return self._bytes_written - - def run(self) -> None: - """The blocking method to execute the core IO object, that connects - and then blocks on the IOLoop, exiting when the IOLoop stops. - - """ - self._connect() - if not self._socket: - LOGGER.warning('Could not connect to %s:%s', self._args['host'], - self._args['port']) - return - - LOGGER.debug('Socket connected') - - # Create the remote name - local_socket = self._socket.getsockname() - peer_socket = self._socket.getpeername() - self._remote_name = (f'{local_socket[0]}:{local_socket[1]} ' - f'-> {peer_socket[0]}:{peer_socket[1]}') - self._loop = _IOLoop(self._socket, self.on_error, self.on_read, - self.on_write, self._write_queue, self._events, - self._write_listener, self._exceptions) - self._loop.run() - if not self._exceptions.empty() and \ - not self._events.is_set(events.EXCEPTION_RAISED): - self._events.set(events.EXCEPTION_RAISED) - LOGGER.debug('Exiting IO.run') - - def on_error(self, exception: socket.error) -> None: - """Common functions when a socket error occurs. Make sure to set closed - and add the exception, and note an exception event. - - :param exception: The socket error - - """ - LOGGER.critical('In on_error: %r', exception) - if self._events.is_set(events.SOCKET_CLOSED): - return - args = [self._args['host'], self._args['port'], str(exception)] - if self._channels[0][0].open: - self._exceptions.put(exceptions.ConnectionResetException(*args)) - else: - self._exceptions.put(exceptions.ConnectionException(*args)) - self._events.set(events.EXCEPTION_RAISED) - - def on_read(self, data: bytes) -> None: - """Append the data that is read to the buffer and try and parse - frames out of it. - - :param data: The data that has been read in - - """ - self._buffer += data - - while self._buffer: - - # Read and process data - value = self._read_frame() - - # Increment the byte counter used by the heartbeat timer - self._bytes_read += len(value) - - # Break out if a frame could not be decoded - if self._buffer and value[0] is None: - break - - # If it's channel 0, call the Channel0 directly - if value[0] == 0: - with self._lock: - self._channels[0][0].on_frame(value[1]) - continue - - self._add_frame_to_read_queue(value[0], value[1]) - - def on_write(self, bytes_written: int) -> None: - """Keep track of how many bytes have been written. - - :param bytes_written: How many bytes were written to the socket. - - """ - self._bytes_written += bytes_written - - def stop(self) -> None: - """Stop the IO Layer due to exception or other problem.""" - self._close() + with self._lock: + return self._bytes_written @property - def write_trigger(self) -> socket.socket: - """Return the write trigger socket. + def remote_name(self) -> str: + """Return the remote name of the socket""" + return self._remote_name - :rtype: socket.socket + def write_frame(self, channel: int, frame_value: base.Frame) -> None: + with self._lock: + payload = frame.marshal(frame_value, channel) + self._write_buffer.append(payload) - """ - return self._write_trigger - - def _add_frame_to_read_queue(self, - channel_id: int, - value: pamqp_base.Frame): - """Add the frame to the stack by creating the key value used in - expectations and then add it to the list. - - :param int channel_id: The channel id the frame was received on - :param value: The frame to add - - """ - self._channels[channel_id][1].put(value) + def stop(self): + pass def _close(self) -> None: """Close the socket and set the proper event states""" - self._events.clear(events.SOCKET_OPENED) + self._events.clear(rabbitpy.events.SOCKET_OPENED) self._set_state(self.CLOSING) - if hasattr(self, '_socket') and self._socket: - self._close_socket(self._socket) - if hasattr(self, '_write_listener') and self._write_listener: - self._close_socket(self._write_listener) - if hasattr(self, '_write_trigger') and self._write_trigger: - self._close_socket(self._write_trigger) - if hasattr(self, '_server_socket') and self._server_socket: - self._close_socket(self._server_sock) - if not self._events.is_set(events.SOCKET_CLOSED): - self._events.set(events.SOCKET_CLOSED) - if not self.closed: + if hasattr(self, '_socket'): + self._close_socket() + if not self._events.is_set(rabbitpy.events.SOCKET_CLOSED): + self._events.set(rabbitpy.events.SOCKET_CLOSED) + if not self.is_closed: self._set_state(self.CLOSED) - @staticmethod - def _close_socket(sock: socket.socket) -> None: + def _close_socket(self) -> None: + if hasattr(self, '_ioloop'): + self._ioloop.remove_reader(self._socket) + self._ioloop.remove_writer(self._socket) try: - sock.shutdown(socket.SHUT_RDWR) - sock.close() - except (OSError, socket.error): + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except OSError: pass - def _connect_socket(self, - sock: socket.socket, - address: typing.Tuple[str, int]) -> None: - """Connect the socket to the specified host and port, setting the - timeout. - - :param sock: The socket to connect - :param address: The address tuple to connect to - - """ - LOGGER.debug('Connecting to %r', address) - sock.settimeout(self._args['timeout']) - sock.connect(address) - - def _connect(self) -> None: + async def _connect(self) -> None: """Connect to the RabbitMQ Server :raises: ConnectionException @@ -560,30 +152,33 @@ def _connect(self) -> None: """ self._set_state(self.OPENING) sock = None - for (address_family, sock_type, - proto, _cname, sock_addr) in self._get_addr_info(): + for (addr_family, sock_type, protocol, _cname, sock_addr) \ + in self._get_addr_info(): try: - sock = self._create_socket(address_family, sock_type, proto) - self._connect_socket(sock, sock_addr) - break - except socket.error as error: + sock = self._create_socket(addr_family, sock_type, protocol) + sock.connect(sock_addr) + except OSError as error: LOGGER.debug('Error connecting to %r: %s', sock_addr, error) sock = None continue + else: + break if not sock: - args = [ - self._args['host'], self._args['port'], 'Could not connect' - ] - self._exceptions.put(exceptions.ConnectionException(*args)) - self._events.set(events.EXCEPTION_RAISED) + self._exceptions.put( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, 'Could not connect')) + self._events.set(rabbitpy.events.EXCEPTION_RAISED) return self._socket = sock - self._events.set(events.SOCKET_OPENED) + self._socket.setblocking(False) + self._socket.settimeout(self._timeout) + self._events.set(rabbitpy.events.SOCKET_OPENED) self._set_state(self.OPEN) - def _create_socket(self, address_family: int, + def _create_socket(self, + address_family: int, sock_type: int, protocol: int) \ -> typing.Union[socket.socket, ssl.SSLSocket]: @@ -591,115 +186,137 @@ def _create_socket(self, address_family: int, :param address_family: The address family to use when creating :param sock_type: The type of socket to create - :param protocol: The protocol to use """ sock = socket.socket(address_family, sock_type, protocol) - if self._args['ssl']: - kwargs = {'sock': sock, 'server_side': False} - for argv, key in self.SSL_KWARGS.items(): - if self._args[key]: - kwargs[argv] = self._args[key] - LOGGER.debug('Wrapping socket for SSL: %r', kwargs) - context = ssl.SSLContext() - return context.wrap_socket(**kwargs) + context = self._get_ssl_context() + if context: + return context.wrap_socket(sock=sock) return sock def _disconnect_socket(self) -> None: """Close the existing socket connection""" self._socket.close() - def _get_addr_info(self) \ - -> list[Any] | list[tuple[ - AddressFamily, SocketKind, int, str, tuple[str, int] | tuple[ - str, int, int, int]]]: + def _get_addr_info(self) -> AddrInfo: family = socket.AF_UNSPEC if not socket.has_ipv6: family = socket.AF_INET try: res = socket.getaddrinfo( - self._args['host'], self._args['port'], family, - socket.SOCK_STREAM, 0) - except socket.error as error: - LOGGER.error('Could not resolve %s: %s', - self._args['host'], error, exc_info=True) + self._host, self._port, family, socket.SOCK_STREAM, 0) + except OSError as error: + LOGGER.exception('Could not resolve %s: %s', self._host, error) return [] return res + def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: + """Return the configured SSLContext to use if needed""" + if self._use_ssl: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if self._ssl_options.get('cafile') \ + or self._ssl_options.get('capath'): + ssl_context.load_verify_locations( + cafile=self._ssl_options.get('cafile'), + capath=self._ssl_options.get('capath')) + else: + ssl_context.load_default_certs(ssl.Purpose.SERVER_AUTH) + if self._ssl_options.get('certfile'): + ssl_context.load_cert_chain( + self._ssl_options.get('certfile'), + self._ssl_options.get('keyfile')) + if self._ssl_options.get('verify') is not None: + ssl_context.verify_mode = self._ssl_options['verify'] + if self._ssl_options.get('verify') == ssl.CERT_NONE: + ssl_context.check_hostname = False + return ssl_context + return None + + def _on_connection_lost(self, error: typing.Optional[Exception]) -> None: + LOGGER.exception('Connection lost: %r', error) + with self._lock: + if error: + self._exceptions.put(error) + self._events.set(rabbitpy.events.EXCEPTION_RAISED) + self._events.set(rabbitpy.events.SOCKET_CLOSED) + @staticmethod - def _get_frame(value: bytes) \ - -> typing.Tuple[bytes, typing.Optional[int], - typing.Optional[pamqp_base.Frame]]: - """Get the pamqp frame from the string value. + def _on_data_received(value: bytes) \ + -> tuple[bytes, + typing.Optional[int], + typing.Optional[base.Frame], + int]: + """Get the pamqp frame from the value read from the socket. :param value: The value to parse for a pamqp frame - :return Remainder of value, channel id, and frame value + :return: Remainder of value, channel id, frame value, and bytes read + """ if not value: - return value, None, None + return value, None, None, 0 try: byte_count, channel_id, frame_in = frame.unmarshal(value) - except pamqp_exceptions.UnmarshalingException: - return value, None, None - except pamqp_exceptions.AMQPFrameError as error: - LOGGER.error('Failed to demarshal: %r', error, exc_info=True) - LOGGER.debug(value) - return value, None, None - return value[byte_count:], channel_id, frame_in - - def _read_frame(self) \ - -> typing.Tuple[int, typing.Optional[pamqp_base.Frame]]: - """Read from the buffer and try and get the demarshaled frame. + except (pamqp.exceptions.UnmarshalingException, ValueError): + return value, None, None, 0 + return value[byte_count:], channel_id, frame_in, byte_count - :rtype (int, pamqp.specification.Frame): The channel and frame + def _on_read_ready(self) -> None: + """Append the data that is read to the buffer and try and parse + frames out of it. """ - self._buffer, chan_id, value = self._get_frame(self._buffer) - return chan_id, value + with self._lock: + self._buffer += self._socket.recv(32768) + while self._buffer: + remaining, channel_id, frame_value, bytes_read =\ + self._on_data_received(self._buffer) + if remaining == self._buffer: + break + self._buffer = remaining + self._bytes_read += bytes_read + if channel_id not in self._channels: + self._exceptions.put( + rabbitpy.exceptions.ReceivedOnClosedChannelException( + channel_id)) + self._channels[channel_id].put(frame_value) + + def _on_write_ready(self) -> None: + """Write the next frame to the socket""" + with self._lock: + try: + payload = self._write_buffer.popleft() + except IndexError: + return + try: + bytes_written = self._socket.send(payload) + except socket.timeout: + LOGGER.warning('Timed out writing %i bytes to socket', + len(payload)) + self._write_buffer.appendleft(payload) + except OSError as error: + if error.errno == 35: + LOGGER.debug('socket resource temp unavailable') + self._write_buffer.appendleft(payload) + else: + self._on_error(error) + else: + self._bytes_written += bytes_written - def _remote_close_channel(self, channel_id: int, - value: pamqp_base.Frame) -> None: - """Invoke the on_channel_close code in the specified channel. This will - block the IO loop unless the exception is caught. + def _on_error(self, exception: OSError) -> None: + """Common functions when a socket error occurs. Make sure to set closed + and add the exception, and note an exception event. - :param int channel_id: The channel to remote close - :param value: The Channel.Close frame + :param exception: The socket error """ - self._channels[channel_id][0].on_remote_close(value) - - def _socketpair(self) -> typing.Tuple[socket.socket, socket.socket]: - """Return a socket pair regardless of platform.""" - try: - server, client = socket.socketpair() - except AttributeError: - # Connect in Windows - LOGGER.debug('Falling back to emulated socketpair behavior') - - # Create the listening server socket & bind it to a random port - self._server_sock = socket.socket( - socket.AF_INET, socket.SOCK_STREAM) - self._server_sock.bind(('127.0.0.1', 0)) - - # Get the port for the notifying socket to connect to - port = self._server_sock.getsockname()[1] - - # Create the notifying client socket and connect using a timer - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - def connect(): - """Connect to the client to the server socket pair""" - client.connect(('127.0.0.1', port)) - - timer = threading.Timer(0.01, connect) - timer.start() - - # Have the listening server socket listen and accept the connect - self._server_sock.listen(0) - server, _unused = self._server_sock.accept() - - # Don't block on either socket - server.setblocking(False) - client.setblocking(False) - - return server, client + if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): + return + args = [self._host, self._port, str(exception)] + """ + if self._channels[0][0].open: + self._exceptions.put( + rabbitpy_exceptions.ConnectionResetException(*args)) + else: + """ + self._exceptions.put(rabbitpy.exceptions.ConnectionException(*args)) + self._events.set(rabbitpy.events.EXCEPTION_RAISED) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..0883d5a --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,246 @@ +import asyncio +import logging +import queue +import struct +import typing +import unittest + +from pamqp import commands, constants, header + +import rabbitpy.events +from rabbitpy import events, exceptions, io, url_parser +from tests import mixins + +LOGGER = logging.getLogger(__name__) + + +class TestCase(unittest.TestCase): + + def setUp(self): + """Set up a common IO instance for tests.""" + self.events = events.Events() + self.exceptions = queue.Queue() + self.read_queue = queue.Queue() + self.write_queue = queue.Queue() + + def tearDown(self): + try: + exc = self.exceptions.get(False) + except queue.Empty: + pass + else: + LOGGER.exception('Uncaught exception: %r', exc) + raise exc + + +class TestIO(TestCase): + + def setUp(self): + """Set up a common IO instance for tests.""" + super().setUp() + self.io = io.IO( + 'localhost', 5672, False, {}, self.events, + self.exceptions) + self.io.add_channel(0, self.read_queue) + + def test_initialization(self): + """Test IO class initialization.""" + self.assertEqual(self.io._host, 'localhost') + self.assertEqual(self.io._port, 5672) + self.assertFalse(self.io._use_ssl) + self.assertIsInstance(self.io._channels, dict) + self.assertIsInstance(self.io._events, events.Events) + self.assertIsInstance(self.io._exceptions, queue.Queue) + self.assertFalse(self.io._running.is_set()) + + def test_on_data_received(self): + data_in = ( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce') + remaining, channel, frame, count = self.io._on_data_received(data_in) + self.assertEqual(remaining, b'') + self.assertEqual(channel, 0) + self.assertIsInstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + self.assertEqual(count, 335) + + def test_on_data_received_empty(self): + remaining, channel, frame, byte_count = self.io._on_data_received(b'') + self.assertEqual(remaining, b'') + self.assertIsNone(channel) + self.assertIsNone(frame) + self.assertEqual(byte_count, 0) + + def test_on_data_received_error(self): + payload = struct.pack('>L', 42949) + data_in = b''.join([struct.pack('>BHI', 1, 0, len(payload)), + payload, constants.FRAME_END_CHAR]) + remaining, channel, frame, count = self.io._on_data_received(data_in) + self.assertEqual(remaining, data_in) + self.assertIsNone(channel) + self.assertIsNone(frame) + self.assertEqual(count, 0) + + def test_on_error(self): + self.io._on_error(OSError('Foo')) + error = self.exceptions.get(True, 3) + self.assertIsInstance(error, exceptions.ConnectionException) + self.assertTrue(self.events.is_set(events.EXCEPTION_RAISED)) + + def test_on_error_already_closed(self): + self.events.set(events.SOCKET_CLOSED) + self.io._on_error(OSError('Foo')) + with self.assertRaises(queue.Empty): + self.exceptions.get(False) + + +class MockServer(asyncio.Protocol): + + instances = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._expectation: typing.Optional[bytes] = None + self._response: typing.Optional[bytes] = None + self._transport: typing.Optional[asyncio.Transport] = None + MockServer.instances.append(self) + + def connection_lost(self, exc): + MockServer.instances.remove(self) + + def connection_made(self, transport): + self._transport = transport + LOGGER.debug('Connection from %r', + transport.get_extra_info('peername')) + + def data_received(self, data): + if self._expectation and data != self._expectation: + return self._transport.write(b'Bad data: ' + data) + if self._response: + self._transport.write(self._response) + + def set_expectation(self, expectation: bytes): + self._expectation = expectation + + def set_response(self, response: bytes): + self._response = response + + +class SocketCommunicationTestCase(mixins.EnvironmentVariableMixin, + TestCase, + unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + loop = asyncio.get_running_loop() + self.server = await loop.create_server(MockServer, '127.0.0.1') + args = url_parser.parse( + f'amqp://127.0.0.1:{self.server.sockets[0].getsockname()[1]}') + self.io = io.IO( + args['host'], args['port'], args['ssl'], args['ssl_options'], + self.events, self.exceptions) + self.io.add_channel(0, self.read_queue) + self.io.add_channel(1, self.read_queue) + self.io.start() + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) + self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + + async def asyncTearDown(self): + self.server.close() + await super().asyncTearDown() + + async def get_mock_server(self, retries=10, delay=0.1): + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) + self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + socket_local = self.io._socket.getsockname() # (address, port) + socket_remote = self.io._socket.getpeername() # (address, port) + for _attempt in range(retries): + for instance in MockServer.instances: + if instance._transport: + server = instance._transport.get_extra_info('sockname') + peer = instance._transport.get_extra_info('peername') + if socket_local == peer and socket_remote == server: + return instance # Return the matched MockServer + await asyncio.sleep(delay) + raise AssertionError('No MockServer found') + + async def write_protocol_header(self): + self.io.write_frame(0, header.ProtocolHeader()) + await asyncio.sleep(0.1) + + async def test_on_read_ready(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce') + + await self.write_protocol_header() + + self.assertEqual(self.io._buffer, b'') + self.assertEqual(self.io.bytes_received, 335) + + frame = self.io._channels[0].get(True, 3) + self.assertIsInstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + + 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') + mock_server.set_response( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce\x01\x00' + b'\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce') + + await self.write_protocol_header() + + self.assertEqual(self.io.bytes_received, 347) + + frame = self.io._channels[0].get(True, 3) + self.assertIsInstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + + frame = self.io._channels[1].get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) + + 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') + mock_server.set_response( + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00') + + await self.write_protocol_header() + + self.assertEqual(self.io._buffer, b'\x01\x00\x00') + self.assertEqual(self.io.bytes_received, 12) + frame = self.read_queue.get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) From b3dbafb8cb93b374e465014fe7448e72f2be4a0b Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 7 Jan 2025 13:28:12 -0500 Subject: [PATCH 25/59] Refactor IO socket handling for enhanced connection management Replaced outdated state management with asyncio-based logic for cleaner and more consistent socket handling. Added new methods and properties to streamline connection states, error handling, and socket closure. Updated tests accordingly to ensure proper validation of the new behavior. --- rabbitpy/io.py | 119 ++++++++++++++++++++--------------------------- tests/test_io.py | 46 ++++++++++++++++-- 2 files changed, 92 insertions(+), 73 deletions(-) diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 655c777..f73d974 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -24,7 +24,7 @@ ] -class IO(threading.Thread, state.StatefulBase): +class IO(threading.Thread): def __init__(self, host: str, @@ -49,6 +49,8 @@ def __init__(self, self._use_ssl = use_ssl self._buffer = b'' + self._closed = asyncio.Event() + self._closed.set() self._lock = threading.Lock() self._bytes_read = 0 self._bytes_written = 0 @@ -56,37 +58,12 @@ def __init__(self, collections.defaultdict(queue.Queue)) self._ioloop: typing.Optional[asyncio.AbstractEventLoop] = None self._remote_name: typing.Optional[str] = None - self._running = threading.Event() + self._socket: typing.Optional[socket.socket] = None self._write_buffer = collections.deque() - self._write_queue: queue.Queue = queue.Queue() def run(self): asyncio.run(self._run()) - try: - self._ioloop.run_forever() - finally: - self._ioloop.close() - - async def _run(self): - self._ioloop = asyncio.get_running_loop() - await self._connect() - - self._ioloop.add_reader(self._socket, self._on_read_ready) - self._ioloop.add_writer(self._socket, self._on_write_ready) - - local_sock = self._socket.getsockname() - peer_sock = self._socket.getpeername() - self._remote_name = \ - f'{local_sock[0]}:{local_sock[1]} -> {peer_sock[0]}:{peer_sock[1]}' - - if self.is_open: - LOGGER.info('Connected to %s:%i', self._host, self._port) - self._running.set() - while self._running.is_set(): - await asyncio.sleep(10) - LOGGER.info('Disconnected from %s:%i', self._host, self._port) - self.stop() def add_channel(self, channel: int, read_queue: queue.Queue) -> None: """Add a channel to the channel queue dict for dispatching frames @@ -96,7 +73,16 @@ def add_channel(self, channel: int, read_queue: queue.Queue) -> None: :param read_queue: Queue for sending frames to the channel """ - self._channels[int(channel)] = read_queue + with self._lock: + self._channels[int(channel)] = read_queue + + def close(self) -> None: + """Close the socket and set the proper event states""" + with self._lock: + self._events.clear(rabbitpy.events.SOCKET_OPENED) + self._close_socket() + self._closed.set() + self._events.set(rabbitpy.events.SOCKET_CLOSED) @property def bytes_received(self) -> int: @@ -110,39 +96,29 @@ def bytes_written(self) -> int: with self._lock: return self._bytes_written + @property + def is_connected(self): + """Returns True if the socket is connected""" + with self._lock: + return not self._closed.is_set() + @property def remote_name(self) -> str: """Return the remote name of the socket""" - return self._remote_name + with self._lock: + return self._remote_name def write_frame(self, channel: int, frame_value: base.Frame) -> None: with self._lock: payload = frame.marshal(frame_value, channel) self._write_buffer.append(payload) - def stop(self): - pass - - def _close(self) -> None: - """Close the socket and set the proper event states""" - self._events.clear(rabbitpy.events.SOCKET_OPENED) - self._set_state(self.CLOSING) - if hasattr(self, '_socket'): - self._close_socket() - if not self._events.is_set(rabbitpy.events.SOCKET_CLOSED): - self._events.set(rabbitpy.events.SOCKET_CLOSED) - if not self.is_closed: - self._set_state(self.CLOSED) - def _close_socket(self) -> None: - if hasattr(self, '_ioloop'): - self._ioloop.remove_reader(self._socket) - self._ioloop.remove_writer(self._socket) - try: - self._socket.shutdown(socket.SHUT_RDWR) - self._socket.close() - except OSError: - pass + """Close the socket, removing the FDs from the IOLoop""" + self._ioloop.remove_reader(self._socket) + self._ioloop.remove_writer(self._socket) + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() async def _connect(self) -> None: """Connect to the RabbitMQ Server @@ -150,7 +126,6 @@ async def _connect(self) -> None: :raises: ConnectionException """ - self._set_state(self.OPENING) sock = None for (addr_family, sock_type, protocol, _cname, sock_addr) \ in self._get_addr_info(): @@ -158,8 +133,9 @@ async def _connect(self) -> None: sock = self._create_socket(addr_family, sock_type, protocol) sock.connect(sock_addr) except OSError as error: - LOGGER.debug('Error connecting to %r: %s', sock_addr, error) + sock.close() sock = None + LOGGER.debug('Error connecting to %r: %s', sock_addr, error) continue else: break @@ -171,11 +147,11 @@ async def _connect(self) -> None: self._events.set(rabbitpy.events.EXCEPTION_RAISED) return + self._closed.clear() self._socket = sock self._socket.setblocking(False) self._socket.settimeout(self._timeout) self._events.set(rabbitpy.events.SOCKET_OPENED) - self._set_state(self.OPEN) def _create_socket(self, address_family: int, @@ -194,10 +170,6 @@ def _create_socket(self, return context.wrap_socket(sock=sock) return sock - def _disconnect_socket(self) -> None: - """Close the existing socket connection""" - self._socket.close() - def _get_addr_info(self) -> AddrInfo: family = socket.AF_UNSPEC if not socket.has_ipv6: @@ -232,14 +204,6 @@ def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: return ssl_context return None - def _on_connection_lost(self, error: typing.Optional[Exception]) -> None: - LOGGER.exception('Connection lost: %r', error) - with self._lock: - if error: - self._exceptions.put(error) - self._events.set(rabbitpy.events.EXCEPTION_RAISED) - self._events.set(rabbitpy.events.SOCKET_CLOSED) - @staticmethod def _on_data_received(value: bytes) \ -> tuple[bytes, @@ -288,7 +252,7 @@ def _on_write_ready(self) -> None: except IndexError: return try: - bytes_written = self._socket.send(payload) + self._socket.sendall(payload) except socket.timeout: LOGGER.warning('Timed out writing %i bytes to socket', len(payload)) @@ -300,7 +264,7 @@ def _on_write_ready(self) -> None: else: self._on_error(error) else: - self._bytes_written += bytes_written + self._bytes_written += len(payload) def _on_error(self, exception: OSError) -> None: """Common functions when a socket error occurs. Make sure to set closed @@ -320,3 +284,22 @@ def _on_error(self, exception: OSError) -> None: """ self._exceptions.put(rabbitpy.exceptions.ConnectionException(*args)) self._events.set(rabbitpy.events.EXCEPTION_RAISED) + + async def _run(self): + """Core logic that connects and blocks until the socket is closed""" + self._ioloop = asyncio.get_running_loop() + + await self._connect() + + if not self._socket: + return + + self._ioloop.add_reader(self._socket, self._on_read_ready) + self._ioloop.add_writer(self._socket, self._on_write_ready) + + local_sock = self._socket.getsockname() + peer_sock = self._socket.getpeername() + self._remote_name = \ + f'{local_sock[0]}:{local_sock[1]} -> {peer_sock[0]}:{peer_sock[1]}' + + await self._closed.wait() diff --git a/tests/test_io.py b/tests/test_io.py index 0883d5a..e20bfaf 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,6 +1,7 @@ import asyncio import logging import queue +import random import struct import typing import unittest @@ -51,7 +52,7 @@ def test_initialization(self): self.assertIsInstance(self.io._channels, dict) self.assertIsInstance(self.io._events, events.Events) self.assertIsInstance(self.io._exceptions, queue.Queue) - self.assertFalse(self.io._running.is_set()) + self.assertFalse(self.io.is_connected) def test_on_data_received(self): data_in = ( @@ -103,6 +104,16 @@ def test_on_error_already_closed(self): with self.assertRaises(queue.Empty): self.exceptions.get(False) + def test_connect_failure(self): + instance = io.IO( + 'localhost', random.randint(1024, 32768), # noqa: S311 + False, {}, self.events, self.exceptions) + instance.start() + self.assertIsInstance( + self.exceptions.get(True, 3), + rabbitpy.exceptions.ConnectionException) + self.assertFalse(self.io.is_connected) + class MockServer(asyncio.Protocol): @@ -124,11 +135,17 @@ def connection_made(self, transport): transport.get_extra_info('peername')) def data_received(self, data): + LOGGER.debug('Received %r', data) if self._expectation and data != self._expectation: return self._transport.write(b'Bad data: ' + data) if self._response: self._transport.write(self._response) + # Connection.CloseOk + if data == '\x01\x00\x00\x00\x00\x00\x04\x00\n\x003\xce': + LOGGER.debug('Closing connection') + self._transport.close() + def set_expectation(self, expectation: bytes): self._expectation = expectation @@ -136,9 +153,9 @@ def set_response(self, response: bytes): self._response = response -class SocketCommunicationTestCase(mixins.EnvironmentVariableMixin, - TestCase, - unittest.IsolatedAsyncioTestCase): +class MockServerTestCase(mixins.EnvironmentVariableMixin, + TestCase, + unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): await super().asyncSetUp() loop = asyncio.get_running_loop() @@ -153,6 +170,7 @@ async def asyncSetUp(self): self.io.start() connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + self.assertTrue(self.io.is_connected) async def asyncTearDown(self): self.server.close() @@ -174,7 +192,11 @@ async def get_mock_server(self, retries=10, delay=0.1): raise AssertionError('No MockServer found') async def write_protocol_header(self): - self.io.write_frame(0, header.ProtocolHeader()) + await self.write_frame(0, header.ProtocolHeader()) + self.assertEqual(self.io.bytes_written, 8) + + async def write_frame(self, channel: int, frame): + self.io.write_frame(channel, frame) await asyncio.sleep(0.1) async def test_on_read_ready(self): @@ -244,3 +266,17 @@ async def test_on_data_received_remaining_buffer(self): self.assertEqual(self.io.bytes_received, 12) frame = self.read_queue.get(True, 3) self.assertIsInstance(frame, commands.Tx.RollbackOk) + + async def test_remote_name(self): + mock_server = await self.get_mock_server() + remote = mock_server._transport.get_extra_info('sockname') + local = self.io._socket.getsockname() + self.assertEqual( + self.io.remote_name, + f'{local[0]}:{local[1]} -> {remote[0]}:{remote[1]}') + + async def test_close(self): + _mock_server = await self.get_mock_server() + self.io.close() + self.assertTrue(self.events.is_set(events.SOCKET_CLOSED)) + self.assertFalse(self.io.is_connected) From 68e2bd708f2c02778ff10c4f415c527738df3fa4 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 7 Jan 2025 14:15:12 -0500 Subject: [PATCH 26/59] Handle socket errors, improve SSL context, and add tests This commit adds robust error handling for socket operations and adjustments to `_get_ssl_context` to better handle SSL options like `check_hostname`. New tests are included to validate SSL configurations and edge case behaviors. Minor improvements to logging and connection handling ensure better maintainability and debugging. --- rabbitpy/io.py | 46 ++++++++++++++++++++++++++++++------------- tests/data/ca.crt | 23 ++++++++++++++++++++++ tests/data/client.crt | 23 ++++++++++++++++++++++ tests/data/client.key | 28 ++++++++++++++++++++++++++ tests/test_io.py | 32 ++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 tests/data/ca.crt create mode 100644 tests/data/client.crt create mode 100644 tests/data/client.key diff --git a/rabbitpy/io.py b/rabbitpy/io.py index f73d974..8b72940 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -12,7 +12,6 @@ import rabbitpy.events import rabbitpy.exceptions -from rabbitpy import state LOGGER = logging.getLogger(__name__) MAX_BUFFER_PROCESSING_RETRIES = 5 @@ -63,7 +62,14 @@ def __init__(self, self._write_queue: queue.Queue = queue.Queue() def run(self): - asyncio.run(self._run()) + try: + asyncio.run(self._run()) + except asyncio.CancelledError: + LOGGER.debug('IO thread cancelled') + self.close() + except OSError as error: + LOGGER.exception('Socket error in the connection: %s', error) + self.close() def add_channel(self, channel: int, read_queue: queue.Queue) -> None: """Add a channel to the channel queue dict for dispatching frames @@ -109,16 +115,30 @@ def remote_name(self) -> str: return self._remote_name def write_frame(self, channel: int, frame_value: base.Frame) -> None: + """Write an AMQP frame to the socket. + + :raises: rabbitpy.exceptions.ConnectionException + + """ + if not self.is_connected: + raise rabbitpy.exceptions.ConnectionException( + self._host, self._port, 'Not connected') with self._lock: payload = frame.marshal(frame_value, channel) self._write_buffer.append(payload) def _close_socket(self) -> None: """Close the socket, removing the FDs from the IOLoop""" - self._ioloop.remove_reader(self._socket) - self._ioloop.remove_writer(self._socket) - self._socket.shutdown(socket.SHUT_RDWR) - self._socket.close() + try: + self._ioloop.remove_reader(self._socket) + self._ioloop.remove_writer(self._socket) + except (AttributeError, IndexError): + pass + try: + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except (AttributeError, OSError): + pass async def _connect(self) -> None: """Connect to the RabbitMQ Server @@ -173,6 +193,7 @@ def _create_socket(self, def _get_addr_info(self) -> AddrInfo: family = socket.AF_UNSPEC if not socket.has_ipv6: + LOGGER.info('IPv6 is not available, falling back to IPv4') family = socket.AF_INET try: res = socket.getaddrinfo( @@ -186,6 +207,9 @@ def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: """Return the configured SSLContext to use if needed""" if self._use_ssl: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if self._ssl_options.get('check_hostname') is not None: + ssl_context.check_hostname = \ + self._ssl_options['check_hostname'] if self._ssl_options.get('cafile') \ or self._ssl_options.get('capath'): ssl_context.load_verify_locations( @@ -199,8 +223,6 @@ def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: self._ssl_options.get('keyfile')) if self._ssl_options.get('verify') is not None: ssl_context.verify_mode = self._ssl_options['verify'] - if self._ssl_options.get('verify') == ssl.CERT_NONE: - ssl_context.check_hostname = False return ssl_context return None @@ -238,10 +260,6 @@ def _on_read_ready(self) -> None: break self._buffer = remaining self._bytes_read += bytes_read - if channel_id not in self._channels: - self._exceptions.put( - rabbitpy.exceptions.ReceivedOnClosedChannelException( - channel_id)) self._channels[channel_id].put(frame_value) def _on_write_ready(self) -> None: @@ -289,14 +307,14 @@ async def _run(self): """Core logic that connects and blocks until the socket is closed""" self._ioloop = asyncio.get_running_loop() - await self._connect() + with self._lock: + await self._connect() if not self._socket: return self._ioloop.add_reader(self._socket, self._on_read_ready) self._ioloop.add_writer(self._socket, self._on_write_ready) - local_sock = self._socket.getsockname() peer_sock = self._socket.getpeername() self._remote_name = \ diff --git a/tests/data/ca.crt b/tests/data/ca.crt new file mode 100644 index 0000000..9bdc992 --- /dev/null +++ b/tests/data/ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDvTCCAqWgAwIBAgIUNgAdaPFJWObfAxCYcnph78I9MTkwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcxODU2MThaFw0zNTAxMDUxODU2MThaMGYxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJEQzETMBEGA1UEBwwKV2FzaGluZ3RvbjERMA8G +A1UECgwIcmFiYml0cHkxEDAOBgNVBAsMB1Rlc3RpbmcxEDAOBgNVBAMMB1Rlc3Qg +Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAqbXsgubfXQFmGcHa +3+ci6zxdLCx83ZSYWGCb4B0Hu0kyYZwSFm3HNzeFutS2ZC6qsMGWs6hWLMwCl/QG +vFpKi2NMtxyWsVzczDVlXS2hWBpz6Mwpm8VPPTc99VxtKJVcMCo2djIizQ+D6B7o +NIVvJcj6TwftX/z26/oksKJxhWIMsZ2S6pk9A42THNM5rEs2NumFCKShexQ0ERbK +mkGcCVNwCVFgSzp48zUOZZEsK1164MyXN33pkMgj2/1Yq+RNze9Vkie7IiRlNwY6 +PKbf8rr9wX5MYdOYlJL0RIRAH5BHc+1T1GiRbydA3H+ugquwTw594f+ONGanyKo+ +rJdTAgMBAAGjYzBhMB0GA1UdDgQWBBR4SrV3rq0RQA23SkJW77OmT7zTTjAfBgNV +HSMEGDAWgBR4SrV3rq0RQA23SkJW77OmT7zTTjAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAem4y9lJAv48ZuBMkQSr3 +KJ/xxwccnFZYROuRMf1zxRHwHPw8eyMei34WR5WvYj1B91ynE1bcUbpPbuYPM11H +cMsBPVKortL6RATMYOnsZbJvyRywFlEuqJytXuAV6qktAHbaTz1Hal1mjflU9IrK +2+gJ3ptr1YUiscQM/klo/tRKOI9R5ZxxnYD3w5KDi6KLaQC66XelacMfdQ+1SkYq +ZNkYxByjRPkkV/+W8HyAPq1F4O/MVJgNp323/mjShGTvYpobFGHA0pw7JbbiMbSm +60eKx8Tecg1Fw2iRiWz1M9Yl7mFiRQ56gowBY2UtVyd5Bxijn/8eql3pcX53dIhU ++g== +-----END CERTIFICATE----- diff --git a/tests/data/client.crt b/tests/data/client.crt new file mode 100644 index 0000000..68d6aba --- /dev/null +++ b/tests/data/client.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID3DCCAsSgAwIBAgIUEKZgSjTCJpZeDvL8g96+15+lmzcwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcxODU4NDRaFw0yNjAxMDcxODU4NDRaMHMxCzAJ +BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE +CgwMT3JnYW5pemF0aW9uMREwDwYDVQQLDAhPcmcgVW5pdDEbMBkGA1UEAwwSY2xp +ZW50LmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +pEL0OhepScBTVq2hjOU+/pISOOow8vx884YxJiHn6yh5Fretii8vmGV+ByrQY3Qp +hx9Oh0GPuFZYjzOHZmG0VvNdDRUgl31mvxCTIZqXrGX/oMuDA2mBQEsTo/3UWl40 +aaoYnBnjgAW0+NcyGejrMlKoOkr/5hSPgf8Dnnl87UaGjwGt8IUKPcAaRzPf0Ivk +BDByq5Eso8jEtAHZDDBJLu3xju5gegeVMwe5BVl1bly6fBmz0FNuiPk9smCNpTMZ +HtVGz9ft048cxAzegtDZUxcs7HVKvAB5Ysinp5tEvIq0qXd61jQU5/VORZYMowAp +7lM0KE7CQBuN6Ea2igit/wIDAQABo3UwczAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB +/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUe1HIoD6aZ0EI +0tCWzvrSieUUF2UwHwYDVR0jBBgwFoAUeEq1d66tEUANt0pCVu+zpk+8004wDQYJ +KoZIhvcNAQELBQADggEBACoiwOTzkmEPQZVDncGfBvA/2H/FCLnKD62QbgxarLns ++lFax0n1Qc4d/xDbV/xXE2eqKnHEsL9hMDohKGFQlfqY0OwLGke14B25KuZrZuem +hzMKAdyhLzFaQx3tc2r4tSeOOLTKw0lu3rq1bLjL2Q63v1ogMYKQFZvqQYWFAj5R +1csYUmW5SbcCeHOjiWNtjs8u+1hv76U4W2WV54mzV0o1wWuyDzrgk/Zq8GcTSnKv +u8gf6ueXMmiSM2G66SahA5pLtm8wbocjTBtWvZ2sIqh0g7TD7Czr1fDazRw4ZiH8 +npd4MhX6vje+cmCB0exvNSbMS/497yQGf1kL/8cZseg= +-----END CERTIFICATE----- diff --git a/tests/data/client.key b/tests/data/client.key new file mode 100644 index 0000000..24258e4 --- /dev/null +++ b/tests/data/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkQvQ6F6lJwFNW +raGM5T7+khI46jDy/HzzhjEmIefrKHkWt62KLy+YZX4HKtBjdCmHH06HQY+4VliP +M4dmYbRW810NFSCXfWa/EJMhmpesZf+gy4MDaYFASxOj/dRaXjRpqhicGeOABbT4 +1zIZ6OsyUqg6Sv/mFI+B/wOeeXztRoaPAa3whQo9wBpHM9/Qi+QEMHKrkSyjyMS0 +AdkMMEku7fGO7mB6B5UzB7kFWXVuXLp8GbPQU26I+T2yYI2lMxke1UbP1+3TjxzE +DN6C0NlTFyzsdUq8AHliyKenm0S8irSpd3rWNBTn9U5FlgyjACnuUzQoTsJAG43o +RraKCK3/AgMBAAECggEALvBBCP6s7fnK9shsEy6JJ0bHdUMa1Lyo2g/9KHfk5XJt +07uOGCAuuh46esLVxK2zljywfH8UGil2aglgBjYiSfyGGRBx/Ugk3bFXUfFyqR2G +hETTfdyexigOBz0n+uDTmmQ0XxsYwwMeeUNJOlUwLHOpResbn/w9G/kq6BSwSb75 +wCdGyGVvN0hy3H2c0sUIIa8ppZD2rCJ1bwBbiW5bVExRZfe/HckILMz69xrGfH70 +gc6eHQqHF20J9nihwO+DugY50eb/RY3/FA23umnIHx6Iw2jZit9LTD0s3DOXyOAD +w1DEE3BxiZyiCdyJ83HC5L4dtfZVlxeaIiuTkqX2qQKBgQDToGFw3rUBqR+GL+lW +kuJh43/ERZFAWYhPt/ZDS5sVRX9uO2lG3XsOQNHSOYIfFYubaso5Sjr4HuUWpT71 +uhg+Vj0VdAmNRTjHu0LZPq4LJEm+HLEDShdLTWshgYVvH3y9ZkoRTMUKMPg9W+il +k8G6chZE/dcGasOoKRtsiKSgBwKBgQDGtCH8WQyrwpUpiwT+bSHdZ4eQouPZVCrZ +3GVVRmd3lO4hh2+DrnDKshVs6myFTjy//3MB4R7S8glAoJJb3cXzHXv1yL4n2K0q +1420sedgMM2Ot6+Y8N7rJi3x9PM3TEFnOEn/RbhvW4PhvRXqyAdLQqy0monWrMaZ +RkclChWUSQKBgQDEH54uPDcDkvjkIwLceMPUdEG0Y8R5HoB1YMeZFjhjPkUao3St +eBTS4L+TVDiDFjSLlCxVa7W6vOcSVZJDqHNPUhzKBP/VLJGyiJkrPCuPp9Gvnmdt +5PwxjU37f01p7oRsAqAh6EOzbi6grsyspoKjh5eb2KOuDsPE1FPWAcyPgQKBgQCw +bZSXiUHU6AqlPkiK179v5NLbu6Xve+ooUqau1rpb1SKzJpv/Ic4IS6L2eBcvLc6G +83vcOVSzHDDW7zvE0d9HA/DixQECRENNfOLXg/ba07pQdAw4Efb8d4PoCAREHzMb +QUGCpRcj2O/6aXeiZUUEsAjEu0qUEAiuY1YcLx4F8QKBgQDOHVjpzGS3mM9URwj/ +H8Fo5SDkDr2ozhkOXwcZ8bjVFcmFVWt3Si1DgNif5yL1TcCs7OARNR59upZI+ZIE +D4hUSGpiQZPfJhZofJ9o3RZGo6ri8lVJNvanK9eLGHyyFWkhXkv5x1xSL1+clE+V +c8AJl4utE0z5ry1MggVx/6NaXg== +-----END PRIVATE KEY----- diff --git a/tests/test_io.py b/tests/test_io.py index e20bfaf..ed64ccc 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,7 +1,9 @@ import asyncio import logging +import pathlib import queue import random +import ssl import struct import typing import unittest @@ -114,6 +116,36 @@ def test_connect_failure(self): rabbitpy.exceptions.ConnectionException) self.assertFalse(self.io.is_connected) + def test_disconnected_close(self): + self.io.close() + + def test_write_frame_when_closed(self): + with self.assertRaises(exceptions.ConnectionException): + self.io.write_frame(0, commands.Connection.Start()) + + def test_get_ssl_context(self): + self.io._use_ssl = True + self.io._ssl_options = { + 'check_hostname': False, + 'verify': ssl.CERT_NONE + } + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + self.assertFalse(context.check_hostname) + + def test_get_ssl_context_certs(self): + data_path = pathlib.Path(__file__).parent.resolve() / 'data' + self.io._use_ssl = True + self.io._ssl_options = { + 'ca_certs': data_path / 'ca.crt', + 'certfile': data_path / 'client.crt', + 'keyfile': data_path / 'client.key', + 'verify': ssl.CERT_REQUIRED + } + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + self.assertTrue(context.check_hostname) + class MockServer(asyncio.Protocol): From 4bcb82422d1ca702c5e23a5b67f2a6dc2ff7543c Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 7 Jan 2025 14:15:29 -0500 Subject: [PATCH 27/59] Add support for SSL hostname validation in URL parser This update introduces a new `ssl_check_hostname` option to the `url_parser` module. It allows users to enable or disable server hostname validation for SSL connections. Additionally, minor adjustments are made for consistency in SSL-related parameter handling. --- rabbitpy/url_parser.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index 89a8ccb..d1fe8bb 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -54,6 +54,7 @@ def parse(url: str = DEFAULT_URL) -> dict: - certfile - Path to client certificate file - keyfile - Path to client certificate key - verify - Server certificate validation requirements (1) + - ssl_check_hostname - Whether to validate the server hostname (2) (1) Should be one of three values: @@ -61,6 +62,12 @@ def parse(url: str = DEFAULT_URL) -> dict: - optional - Cert is validated if provided - required - Cert is required and validated + (2) Should be one of the following values: + + - 0, false, no - Do not validate the server hostname + - 1, true, yes - Validate the server hostname + + :param url: The AMQP url passed in :raises: ValueError @@ -104,8 +111,11 @@ def parse(url: str = DEFAULT_URL) -> dict: 'locale': _query_args_value('locale', query_args), 'ssl': use_ssl, 'ssl_options': { - 'cacertfile': _query_args_mk_value( - ['cacertfile', 'ssl_cacert'], query_args), + 'check_hostname': query_args.get('ssl_check_hostname', '').lower() + not in ('0', 'false', 'no'), + 'cafile': _query_args_mk_value( + ['cacertfile', 'ssl_cacert', 'cafile'], query_args), + 'capath': _query_args_value('capath', query_args), 'certfile': _query_args_mk_value( ['certfile', 'ssl_cert'], query_args), 'keyfile': _query_args_mk_value( @@ -179,7 +189,7 @@ def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: """ validation = _query_args_mk_value(['verify', 'ssl_validation'], values) if not validation: - return + return None elif validation not in SSL_CERT_MAP: raise ValueError( f'Unsupported server cert validation option: {validation}') From ada30455d51682908973b4861263d51b7531178e Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 7 Jan 2025 14:16:46 -0500 Subject: [PATCH 28/59] Add support for capath in URL parser Extended the URL parser to include the `capath` parameter, allowing users to specify a directory of CA certificates. This enhances flexibility for configuring SSL/TLS connections. --- rabbitpy/url_parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index d1fe8bb..74838f6 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -51,6 +51,7 @@ def parse(url: str = DEFAULT_URL) -> dict: - frame_max - locale - cacertfile - Path to CA certificate file + - capath - Path to directory containing CA certificates - certfile - Path to client certificate file - keyfile - Path to client certificate key - verify - Server certificate validation requirements (1) From 575c8da93c3e38202046b04bec5c8bfbf346f255 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 7 Jan 2025 18:01:42 -0500 Subject: [PATCH 29/59] Refactor error handling and add new test cases Streamline error handling by introducing `_add_exception` and refactoring `_on_error`. Improve test coverage with dedicated test cases for edge scenarios, including SSL connections and socket-related errors. --- rabbitpy/io.py | 99 ++++++++++---------- rabbitpy/url_parser.py | 3 +- tests/test_io.py | 204 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 232 insertions(+), 74 deletions(-) diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 8b72940..82d8a22 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -61,16 +61,6 @@ def __init__(self, self._write_buffer = collections.deque() self._write_queue: queue.Queue = queue.Queue() - def run(self): - try: - asyncio.run(self._run()) - except asyncio.CancelledError: - LOGGER.debug('IO thread cancelled') - self.close() - except OSError as error: - LOGGER.exception('Socket error in the connection: %s', error) - self.close() - def add_channel(self, channel: int, read_queue: queue.Queue) -> None: """Add a channel to the channel queue dict for dispatching frames to the channel. @@ -90,6 +80,27 @@ def close(self) -> None: self._closed.set() self._events.set(rabbitpy.events.SOCKET_CLOSED) + def run(self) -> None: + """Run the IO thread, invoked by calling IO.start()""" + try: + asyncio.run(self._run()) + except asyncio.CancelledError: + LOGGER.debug('IO thread cancelled') + self.close() + except OSError as error: + self._on_error(error) + self.close() + + def write_frame(self, channel: int, frame_value: base.Frame) -> None: + """Write an AMQP frame to the socket.""" + if not self.is_connected: + return self._add_exception( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, 'Not connected')) + with self._lock: + payload = frame.marshal(frame_value, channel) + self._write_buffer.append(payload) + @property def bytes_received(self) -> int: """Return the number of bytes read/received from RabbitMQ""" @@ -114,18 +125,13 @@ def remote_name(self) -> str: with self._lock: return self._remote_name - def write_frame(self, channel: int, frame_value: base.Frame) -> None: - """Write an AMQP frame to the socket. - - :raises: rabbitpy.exceptions.ConnectionException + # Internal Methods - """ - if not self.is_connected: - raise rabbitpy.exceptions.ConnectionException( - self._host, self._port, 'Not connected') - with self._lock: - payload = frame.marshal(frame_value, channel) - self._write_buffer.append(payload) + def _add_exception(self, exc: Exception) -> None: + """Add an exception to the exception queue""" + LOGGER.debug('Adding exception %r', exc) + self._exceptions.put(exc) + self._events.set(rabbitpy.events.EXCEPTION_RAISED) def _close_socket(self) -> None: """Close the socket, removing the FDs from the IOLoop""" @@ -149,6 +155,7 @@ async def _connect(self) -> None: sock = None for (addr_family, sock_type, protocol, _cname, sock_addr) \ in self._get_addr_info(): + LOGGER.debug('Attempting to connect to %r', sock_addr) try: sock = self._create_socket(addr_family, sock_type, protocol) sock.connect(sock_addr) @@ -161,11 +168,9 @@ async def _connect(self) -> None: break if not sock: - self._exceptions.put( + return self._add_exception( rabbitpy.exceptions.ConnectionException( self._host, self._port, 'Could not connect')) - self._events.set(rabbitpy.events.EXCEPTION_RAISED) - return self._closed.clear() self._socket = sock @@ -191,15 +196,12 @@ def _create_socket(self, return sock def _get_addr_info(self) -> AddrInfo: - family = socket.AF_UNSPEC - if not socket.has_ipv6: - LOGGER.info('IPv6 is not available, falling back to IPv4') - family = socket.AF_INET + family = socket.AF_UNSPEC if socket.has_ipv6 else socket.AF_INET try: res = socket.getaddrinfo( self._host, self._port, family, socket.SOCK_STREAM, 0) except OSError as error: - LOGGER.exception('Could not resolve %s: %s', self._host, error) + LOGGER.debug('Could not resolve %s: %s', self._host, error) return [] return res @@ -246,6 +248,24 @@ def _on_data_received(value: bytes) \ return value, None, None, 0 return value[byte_count:], channel_id, frame_in, byte_count + def _on_error(self, exception: OSError) -> None: + """Common functions when a socket error occurs. Make sure to set closed + and add the exception, and note an exception event. + + :param exception: The socket error + + """ + if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): + return + args = [self._host, self._port, str(exception)] + """ + if self._channels[0][0].open: + self._exceptions.put( + rabbitpy_exceptions.ConnectionResetException(*args)) + else: + """ + self._add_exception(rabbitpy.exceptions.ConnectionException(*args)) + def _on_read_ready(self) -> None: """Append the data that is read to the buffer and try and parse frames out of it. @@ -276,33 +296,14 @@ def _on_write_ready(self) -> None: len(payload)) self._write_buffer.appendleft(payload) except OSError as error: + self._write_buffer.appendleft(payload) if error.errno == 35: LOGGER.debug('socket resource temp unavailable') - self._write_buffer.appendleft(payload) else: self._on_error(error) else: self._bytes_written += len(payload) - def _on_error(self, exception: OSError) -> None: - """Common functions when a socket error occurs. Make sure to set closed - and add the exception, and note an exception event. - - :param exception: The socket error - - """ - if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): - return - args = [self._host, self._port, str(exception)] - """ - if self._channels[0][0].open: - self._exceptions.put( - rabbitpy_exceptions.ConnectionResetException(*args)) - else: - """ - self._exceptions.put(rabbitpy.exceptions.ConnectionException(*args)) - self._events.set(rabbitpy.events.EXCEPTION_RAISED) - async def _run(self): """Core logic that connects and blocks until the socket is closed""" self._ioloop = asyncio.get_running_loop() diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index 74838f6..6b7906d 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -112,7 +112,8 @@ def parse(url: str = DEFAULT_URL) -> dict: 'locale': _query_args_value('locale', query_args), 'ssl': use_ssl, 'ssl_options': { - 'check_hostname': query_args.get('ssl_check_hostname', '').lower() + 'check_hostname': _query_args_value( + 'ssl_check_hostname', query_args, '').lower() not in ('0', 'false', 'no'), 'cafile': _query_args_mk_value( ['cacertfile', 'ssl_cacert', 'cafile'], query_args), diff --git a/tests/test_io.py b/tests/test_io.py index ed64ccc..4c8f45f 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -3,11 +3,14 @@ import pathlib import queue import random +import socket import ssl import struct import typing import unittest +from unittest import mock +import pamqp.frame from pamqp import commands, constants, header import rabbitpy.events @@ -16,6 +19,8 @@ LOGGER = logging.getLogger(__name__) +DATA_PATH = pathlib.Path(__file__).parent.resolve() / 'data' + class TestCase(unittest.TestCase): @@ -26,6 +31,11 @@ def setUp(self): self.read_queue = queue.Queue() self.write_queue = queue.Queue() + def assertExceptionAdded(self, expectation): + error = self.exceptions.get(False) + self.assertIsInstance(error, expectation) + self.assertTrue(self.events.is_set(events.EXCEPTION_RAISED)) + def tearDown(self): try: exc = self.exceptions.get(False) @@ -96,9 +106,7 @@ def test_on_data_received_error(self): def test_on_error(self): self.io._on_error(OSError('Foo')) - error = self.exceptions.get(True, 3) - self.assertIsInstance(error, exceptions.ConnectionException) - self.assertTrue(self.events.is_set(events.EXCEPTION_RAISED)) + self.assertExceptionAdded(exceptions.ConnectionException) def test_on_error_already_closed(self): self.events.set(events.SOCKET_CLOSED) @@ -111,19 +119,26 @@ def test_connect_failure(self): 'localhost', random.randint(1024, 32768), # noqa: S311 False, {}, self.events, self.exceptions) instance.start() - self.assertIsInstance( - self.exceptions.get(True, 3), - rabbitpy.exceptions.ConnectionException) + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 1) + self.assertFalse(connected, 'Should not have connected') + self.assertExceptionAdded(exceptions.ConnectionException) self.assertFalse(self.io.is_connected) def test_disconnected_close(self): self.io.close() def test_write_frame_when_closed(self): - with self.assertRaises(exceptions.ConnectionException): - self.io.write_frame(0, commands.Connection.Start()) + self.io.write_frame(0, commands.Connection.Start()) + self.assertExceptionAdded(exceptions.ConnectionException) def test_get_ssl_context(self): + self.io._use_ssl = True + self.io._ssl_options = {} + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + self.assertTrue(context.check_hostname) + + def test_get_ssl_context_verify_none(self): self.io._use_ssl = True self.io._ssl_options = { 'check_hostname': False, @@ -134,17 +149,17 @@ def test_get_ssl_context(self): self.assertFalse(context.check_hostname) def test_get_ssl_context_certs(self): - data_path = pathlib.Path(__file__).parent.resolve() / 'data' self.io._use_ssl = True self.io._ssl_options = { - 'ca_certs': data_path / 'ca.crt', - 'certfile': data_path / 'client.crt', - 'keyfile': data_path / 'client.key', + 'cafile': DATA_PATH / 'ca.crt', + 'certfile': DATA_PATH / 'client.crt', + 'keyfile': DATA_PATH / 'client.key', 'verify': ssl.CERT_REQUIRED } context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) self.assertTrue(context.check_hostname) + self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED) class MockServer(asyncio.Protocol): @@ -188,21 +203,17 @@ def set_response(self, response: bytes): class MockServerTestCase(mixins.EnvironmentVariableMixin, TestCase, unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): await super().asyncSetUp() - loop = asyncio.get_running_loop() - self.server = await loop.create_server(MockServer, '127.0.0.1') - args = url_parser.parse( - f'amqp://127.0.0.1:{self.server.sockets[0].getsockname()[1]}') - self.io = io.IO( + self.server = await self._create_server() + args = self._get_io_args() + LOGGER.debug('Connection args: %r', args) + self.io = io.IO( args['host'], args['port'], args['ssl'], args['ssl_options'], self.events, self.exceptions) self.io.add_channel(0, self.read_queue) self.io.add_channel(1, self.read_queue) - self.io.start() - connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) - self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') - self.assertTrue(self.io.is_connected) async def asyncTearDown(self): self.server.close() @@ -223,13 +234,35 @@ async def get_mock_server(self, retries=10, delay=0.1): await asyncio.sleep(delay) raise AssertionError('No MockServer found') + async def write_frame(self, channel: int, frame): + self.io.write_frame(channel, frame) + await asyncio.sleep(0.1) + async def write_protocol_header(self): await self.write_frame(0, header.ProtocolHeader()) self.assertEqual(self.io.bytes_written, 8) - async def write_frame(self, channel: int, frame): - self.io.write_frame(channel, frame) - await asyncio.sleep(0.1) + def _get_io_args(self): + return url_parser.parse(f'amqp://127.0.0.1:{self._server_port}') + + @staticmethod + async def _create_server(): + loop = asyncio.get_running_loop() + return await loop.create_server(MockServer, '127.0.0.1') + + @property + def _server_port(self): + return self.server.sockets[0].getsockname()[1] + + +class MockServerConnectedTestCase(MockServerTestCase): + + async def asyncSetUp(self): + await super().asyncSetUp() + self.io.start() + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) + self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + self.assertTrue(self.io.is_connected) async def test_on_read_ready(self): mock_server = await self.get_mock_server() @@ -312,3 +345,126 @@ async def test_close(self): self.io.close() self.assertTrue(self.events.is_set(events.SOCKET_CLOSED)) self.assertFalse(self.io.is_connected) + + +class EdgeCaseMockServerTestCase(MockServerTestCase): + + @mock.patch('socket.getaddrinfo') + async def test_error_on_getaddrinfo(self, mock_getaddrinfo): + mock_getaddrinfo.side_effect = OSError('Foo') + self.io.start() + await asyncio.sleep(0.1) + self.assertFalse(self.io.is_connected) + self.assertExceptionAdded(exceptions.ConnectionException) + + async def test_write_ready_socket_timeout(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + mock_sendall.side_effect = socket.timeout + self.io.start() + await asyncio.sleep(0.1) + self.assertEqual(self.io.bytes_written, 0) + + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + async def test_write_ready_socket_oserror(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + self.io.start() + await asyncio.sleep(0.1) + mock_sendall.side_effect = OSError('Foo') + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + self.assertExceptionAdded(exceptions.ConnectionException) + + async def test_write_ready_socket_errno_35(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + self.io.start() + await asyncio.sleep(0.1) + mock_sendall.side_effect = Error35(35, 'Mock Error 35') + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + # No exception should have been added + with self.assertRaises(queue.Empty): + self.exceptions.get(False) + + async def test_run_asyncio_cancelled(self): + with mock.patch.object(self.io, '_run') as mock_run: + mock_run.side_effect = asyncio.CancelledError() + self.io.start() + self.assertFalse(self.io.is_connected) + + async def test_run_oserror(self): + with mock.patch.object(self.io, '_run') as mock_run: + mock_run.side_effect = OSError('Mock Error') + self.io.start() + self.assertFalse(self.io.is_connected) + await asyncio.sleep(0.1) + self.assertExceptionAdded(exceptions.ConnectionException) + + +class SSLMockServerTestCase(MockServerTestCase): + + async def asyncSetUp(self): + await super().asyncSetUp() + self.io.start() + await asyncio.sleep(0.1) + + @staticmethod + async def _create_server(): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_verify_locations(DATA_PATH / 'ca.crt') + context.load_cert_chain( + DATA_PATH / 'server.crt', + DATA_PATH / 'server.key') + loop = asyncio.get_running_loop() + server = await loop.create_server(MockServer, '127.0.0.1', ssl=context) + LOGGER.debug("Mock server running on: %s", + server.sockets[0].getsockname()) + return server + + def _get_io_args(self): + return url_parser.parse( + f'amqps://127.0.0.1:{self._server_port}?' + f'ssl_check_hostname=false&ssl_verify=ignore&' + f'cafile=tests%2Fdata%2Fca.crt') + + async def test_on_data_received(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00') + + await self.write_protocol_header() + + self.assertEqual(self.io._buffer, b'\x01\x00\x00') + self.assertEqual(self.io.bytes_received, 12) + frame = self.read_queue.get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) + + +class Error35(OSError): + """Mock Exception for socket.error errno=35""" + def __init__(self, errno, *args): + super().__init__(*args) + self.errno = errno From 2efc8e80fd820f7a916407610e16ebc60bd6a457 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 8 Jan 2025 11:56:21 -0500 Subject: [PATCH 30/59] Add missing certs for IO SSL tests --- tests/data/server.crt | 24 ++++++++++++++++++++++++ tests/data/server.key | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/data/server.crt create mode 100644 tests/data/server.key diff --git a/tests/data/server.crt b/tests/data/server.crt new file mode 100644 index 0000000..4da4215 --- /dev/null +++ b/tests/data/server.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIUY8MyAULCRmoy5f/fuOABgZHzS+kwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcyMTIwMTZaFw0zNTAxMDUyMTIwMTZaMHMxCzAJ +BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE +CgwMT3JnYW5pemF0aW9uMREwDwYDVQQLDAhPcmcgVW5pdDEbMBkGA1UEAwwSc2Vy +dmVyLmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +x6jKJIJVyeng0eOLtR4RDeiZwSzO2AZewyFpj+kPPv7bdycRW23zNQfXi3UI8BHk +aW0aWDvCmEHf5D4+ulze8LsCK6mcr+EUnEYclHSIp+cwW7TsFr5qFhKrqjTwWQkw +A5Yu7QsIcl5+Xa8GDnuSu8Hg5YHlRHgn71qgmEGplQBPx5megPjWEa6a6d1XqusQ +ZizbB5L/6bIS3lV4UmNxkYEQmZGhQPe5Vu/Zx6y0Q6Pddg25GGlx0C94sUaMQiuH +JqOrGKE2YkJ01+sLXEoAfWiANAxqElNJnuAMXeXvQKgC1bl1/DSTF78Aj03YuYxJ +LgiCB/QZBsygAqoElx/pwQIDAQABo4GgMIGdMAwGA1UdEwEB/wQCMAAwDgYDVR0P +AQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMCgGA1UdEQQhMB+CEnNlcnZl +ci5leGFtcGxlLmNvbYIJbG9jYWxob3N0MB0GA1UdDgQWBBR7cvSXwxzK4o4iHWbg +nAY+257gezAfBgNVHSMEGDAWgBR4SrV3rq0RQA23SkJW77OmT7zTTjANBgkqhkiG +9w0BAQsFAAOCAQEASkZ0aljwhUzQo/15v6dzKPUXuFpU1JTPA3Pg04BpcThJuVt5 +Gml5wU207W5+cI+PJW/cF+/MseHQnp/eQOQJXb5GwnkbElbbecaAeCTMpLXsG+Q1 +x2/MEd8E7zxbYbgXM7yJZpdQC1yYVGlv8o07GqLh5p4N4PsqToN+IMBiD8rphEQ6 +hpUWYKGanECGHehP09/jM7XZLKin2kdDEgMwiDsKGkzOymduCtrW8alNc27k4wZk +lllAB4njycElZOEdC0Y1ngYkGNvnKvHKSk1VhO4QTT7ryqHhzSEu4rYrqy/IdAE6 +lSmjODT8UoinXm5gaafJL1DKBxtxar894yhzdA== +-----END CERTIFICATE----- diff --git a/tests/data/server.key b/tests/data/server.key new file mode 100644 index 0000000..a92d622 --- /dev/null +++ b/tests/data/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHqMokglXJ6eDR +44u1HhEN6JnBLM7YBl7DIWmP6Q8+/tt3JxFbbfM1B9eLdQjwEeRpbRpYO8KYQd/k +Pj66XN7wuwIrqZyv4RScRhyUdIin5zBbtOwWvmoWEquqNPBZCTADli7tCwhyXn5d +rwYOe5K7weDlgeVEeCfvWqCYQamVAE/HmZ6A+NYRrprp3Veq6xBmLNsHkv/pshLe +VXhSY3GRgRCZkaFA97lW79nHrLRDo912DbkYaXHQL3ixRoxCK4cmo6sYoTZiQnTX +6wtcSgB9aIA0DGoSU0me4Axd5e9AqALVuXX8NJMXvwCPTdi5jEkuCIIH9BkGzKAC +qgSXH+nBAgMBAAECggEAMIwE3MQ5Nl+RHA/bPkaJiIt8aUlrIQFJhOJIr+aQH7Xp +6Kq98HCHYKdfvGfQbMk48/6El5wuSIXJ+g49g+SFi9y43iN/dw+ASrBMjjdYrBTN +gBr5hd02X9gZ2AjHwSp3+4NGtToHy4FY8hAW5aob513wwHVpaRDbSvqLDoJ2yMTs +J8pq3m0ey/dNMetMZe81W/ETQoDmxPo/3uPaCcA8nB7G0Fh5kOiV8FVKhocAVqAB +Cw8Mf+arwe0s09u8RcxEeCN2kPFRi3HINM3rFfgKkWWRAtZclPSnpOMAdKi3GQb6 +Z6yee+I8pOpt6kFbLkSpBEe5Ve3vOBE8iE91IyXT/QKBgQD+putXadaRO928t5yn +9OKtaBfgN8jT+q29MlBRQKeOMlUih6XNaAHIhUCLRVOorPZod3qTyEYryI6AW4Sh +s6m5Ud8I1n7iy6Fh5+3die79RUXwFJBHJxTmuLNnwrOjKibOMRQhsRrgysABBS24 +QH+T2qZpeHxSCyWiWj3Kh7YnHQKBgQDIt1luiVgfjhJtO0sae8Jc1EQJ+MXj0dvP +IA7joG9eZVq8hw36qZYo1s5LWe6NbZHZwNC88o5stXH968qpWi27RP7PWrYd5yZa +3PdMBIhU1aVzPFHTk6ZAyM4hi5cwfQOrstGcJOLdu+2GtSWyy4Wd9tEVTw4NEQ03 +BnrEN7Z39QKBgQD7aVLKT5zecGt/yQtqKvSs3StNW+Xzmvdy5jyzq5CBbCHvYFsK +i5fPH/fUSFLLIlB4XRVw1/anfW9rPG+aseVsKG95q0NEqGQhZDjMU0TXWlAtMjAr +f2M8UrgVRf3Spmf/hCbrCI6Pxrx/hVxZH5yVHfbUBHdBDO5P9qYw0YG95QKBgAYD +2J5DZ2yqqUq0uC92/gAiLFfQKL4HD/tJDqkrqaq+htWXhoe4hVN7/Hqtm0SJwBEy +gg7nhfUkCuJ9KnmgxjYSf0Bfi42h27hlXtPcXumL7YVijWE1z4mpeuPudv1xAacn +mLiBMntKonei/ho5lyuAtgtZbyZdGHRJsWwn70PxAoGBAM5pK10bJna9UkoA4IOh +iLRfbex2ZIu3xNSuWLCaOgdDR8fiq/3SRlIwH8ZOtGSurYzYhbfWPC1uLZxfuI/x +gLSIaf/zEIxE0jXFXKAeMs4+yFEM0Gujb6LBQC4tGGcRGQhUqNLlXLn5TM+VJoaP +LR78ZZIHzmoHJRJBTb+tRnYi +-----END PRIVATE KEY----- From 1faa66dbcbf3c088be230e2ccd92ef4f2ef546fd Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 8 Jan 2025 12:12:07 -0500 Subject: [PATCH 31/59] Add EnvironmentVariableMixin for test environment handling Introduces a mixin to manage environment variables for tests. It loads variables from a `test.env` file if present, enabling cleaner test setup. Logs an error to stderr if the file is missing. --- tests/mixins.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/mixins.py diff --git a/tests/mixins.py b/tests/mixins.py new file mode 100644 index 0000000..67818c1 --- /dev/null +++ b/tests/mixins.py @@ -0,0 +1,20 @@ +import pathlib +import sys + + +class EnvironmentVariableMixin: + + @classmethod + def setUpClass(cls): + cls.os_environ = {} + path = pathlib.Path(__file__).parent.parent \ + / 'build' / 'test.env' + if not path.exists(): + sys.stderr.write('Failed to find test.env.file\n') + return + with path.open('r') as f: + for line in f: + if line.startswith('export '): + line = line[7:] + name, _, value = line.strip().partition('=') + cls.os_environ[name] = value From 09118345be4a3a3269b3dfd0227b13df234088af Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 8 Jan 2025 12:12:24 -0500 Subject: [PATCH 32/59] Add EnvironmentVariableMixin for test environment handling Introduces a mixin to manage environment variables for tests. It loads variables from a `test.env` file if present, enabling cleaner test setup. Logs an error to stderr if the file is missing. --- tests/mixins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/mixins.py b/tests/mixins.py index 67818c1..f6ee27a 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -7,8 +7,7 @@ class EnvironmentVariableMixin: @classmethod def setUpClass(cls): cls.os_environ = {} - path = pathlib.Path(__file__).parent.parent \ - / 'build' / 'test.env' + path = pathlib.Path(__file__).parent.parent / 'build' / 'test.env' if not path.exists(): sys.stderr.write('Failed to find test.env.file\n') return From 8b8ffa0a32fb8ab7a682ebcc56969d7a749f9e10 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 8 Jan 2025 12:13:10 -0500 Subject: [PATCH 33/59] Refactor exception strings and add ReceivedOnClosedChannelException Updated string formatting in exception classes to use f-strings for improved readability and consistency. Added a new exception, ReceivedOnClosedChannelException, to handle cases where RabbitMQ sends frames on closed channels. Minor formatting improvements were also made throughout the file. --- rabbitpy/exceptions.py | 53 ++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/rabbitpy/exceptions.py b/rabbitpy/exceptions.py index e571f18..9f8d3c9 100644 --- a/rabbitpy/exceptions.py +++ b/rabbitpy/exceptions.py @@ -7,11 +7,13 @@ class RabbitpyException(Exception): """Base exception for all rabbitpy exceptions.""" + pass class AMQPException(RabbitpyException): """Base exception for all AMQP exceptions.""" + pass @@ -31,8 +33,10 @@ class ChannelClosedException(RabbitpyException): """Raised when an action is attempted on a channel that is closed.""" def __str__(self): - return 'Can not perform RPC requests on a closed channel, you must ' \ - 'create a new channel' + return ( + 'Can not perform RPC requests on a closed channel, you must ' + 'create a new channel' + ) class ConnectionException(RabbitpyException): @@ -81,8 +85,8 @@ class RemoteClosedChannelException(RabbitpyException): """ def __str__(self): - return 'Channel {0} was closed by the remote server ' \ - '({1}): {2}'.format(*self.args) + return f'Channel {self.args[0]} was closed by the remote server ' \ + f'({self.args[1]}): {self.args[2]}' class RemoteClosedException(RabbitpyException): @@ -92,8 +96,8 @@ class RemoteClosedException(RabbitpyException): """ def __str__(self): - return 'Connection was closed by the remote server ' \ - '({0}): {1}'.format(*self.args) + return f'Connection was closed by the remote server ' \ + f'({self.args[0]}): {self.args[1]}' class MessageReturnedException(RabbitpyException): @@ -103,8 +107,8 @@ class MessageReturnedException(RabbitpyException): """ def __str__(self): - return 'Message was returned by RabbitMQ: ({0}) ' \ - 'for exchange {1}'.format(*self.args) + return f'Message was returned by RabbitMQ: ({self.args[0]}) ' \ + f'for exchange {self.args[1]}' class NoActiveTransactionError(RabbitpyException): @@ -134,7 +138,14 @@ class NotSupportedError(RabbitpyException): """ def __str__(self): - return 'The selected feature "{0}" is not supported'.format(self.args) + return f'The selected feature "{self.args}" is not supported' + + +class ReceivedOnClosedChannelException(RabbitpyException): + """Raised when RabbitMQ sends an RPC on a channel that is closed.""" + + def __str__(self): + return f'RabbitMQ sent a frame on a closed channel ({self.args[0]})' class TooManyChannelsError(RabbitpyException): @@ -157,8 +168,8 @@ class UnexpectedResponseError(RabbitpyException): """ def __str__(self): - return 'Received an expected response, expected {0}, ' \ - 'received {1}'.format(*self.args) + return f'Received an expected response, expected {self.args[0]}, ' \ + f'received {self.args[1]}' # AMQP Exceptions @@ -170,6 +181,7 @@ class AMQPContentTooLarge(AMQPException): accept at the present time. The client may retry at a later time. """ + pass @@ -178,6 +190,7 @@ class AMQPNoRoute(AMQPException): Undocumented AMQP Soft Error """ + pass @@ -188,6 +201,7 @@ class AMQPNoConsumers(AMQPException): consumers of the queue. """ + pass @@ -197,6 +211,7 @@ class AMQPAccessRefused(AMQPException): due to security settings. """ + pass @@ -205,6 +220,7 @@ class AMQPNotFound(AMQPException): The client attempted to work with a server entity that does not exist. """ + pass @@ -214,6 +230,7 @@ class AMQPResourceLocked(AMQPException): because another client is working with it. """ + pass @@ -223,6 +240,7 @@ class AMQPPreconditionFailed(AMQPException): precondition failed. """ + pass @@ -232,6 +250,7 @@ class AMQPConnectionForced(AMQPException): may retry at some later date. """ + pass @@ -240,6 +259,7 @@ class AMQPInvalidPath(AMQPException): The client tried to work with an unknown virtual host. """ + pass @@ -249,6 +269,7 @@ class AMQPFrameError(AMQPException): strongly implies a programming error in the sending peer. """ + pass @@ -258,6 +279,7 @@ class AMQPSyntaxError(AMQPException): fields. This strongly implies a programming error in the sending peer. """ + pass @@ -268,6 +290,7 @@ class AMQPCommandInvalid(AMQPException): programming error in the client. """ + pass @@ -277,6 +300,7 @@ class AMQPChannelError(AMQPException): opened. This most likely indicates a fault in the client layer. """ + pass @@ -287,6 +311,7 @@ class AMQPUnexpectedFrame(AMQPException): content processing. """ + pass @@ -297,6 +322,7 @@ class AMQPResourceError(AMQPException): entity. """ + pass @@ -306,6 +332,7 @@ class AMQPNotAllowed(AMQPException): the server, due to security settings or by some other criteria. """ + pass @@ -315,6 +342,7 @@ class AMQPNotImplemented(AMQPException): server. """ + pass @@ -325,6 +353,7 @@ class AMQPInternalError(AMQPException): operations. """ + pass @@ -346,5 +375,5 @@ class AMQPInternalError(AMQPException): 506: AMQPResourceError, 530: AMQPNotAllowed, 540: AMQPNotImplemented, - 541: AMQPInternalError + 541: AMQPInternalError, } From 61ac046365363887dff03a93fbb74e5f1e70bb44 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Thu, 9 Jan 2025 19:11:40 -0500 Subject: [PATCH 34/59] Revise URL parsing and improve I/O logic in rabbitpy Refactored URL parsing to handle bytes and updated several utility functions for better type safety and clarity. Enhanced I/O threading with improvements to connection handling, socket operations, and buffer management. Streamlined exception handling and added detailed docstrings for improved maintainability. --- rabbitpy/events.py | 2 +- rabbitpy/io.py | 201 ++++++++++++++++++++--------------------- rabbitpy/url_parser.py | 44 ++++----- 3 files changed, 116 insertions(+), 131 deletions(-) diff --git a/rabbitpy/events.py b/rabbitpy/events.py index 246d62e..748d7bf 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -34,7 +34,7 @@ def description(event_id: int) -> str: """Return the text description for an event""" - return DESCRIPTIONS.get(event_id, event_id) + return DESCRIPTIONS.get(event_id, str(event_id)) class Events: diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 82d8a22..6aa903f 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -7,23 +7,35 @@ import threading import typing +import pamqp.base +import pamqp.body import pamqp.exceptions -from pamqp import base, frame +import pamqp.frame +import pamqp.header +import pamqp.heartbeat import rabbitpy.events import rabbitpy.exceptions LOGGER = logging.getLogger(__name__) -MAX_BUFFER_PROCESSING_RETRIES = 5 AddrInfo = typing.Union[ - list[typing.Any], - list[tuple[socket.AddressFamily, socket.SocketKind, int, str, - tuple[str, int], tuple[str, int, int, int]]] + list[typing.Any], # Fallback for unknown/variable types from getaddrinfo + list[ + tuple[ + socket.AddressFamily, + socket.SocketKind, + int, + str, + tuple[str, int], + typing.Optional[tuple[str, int, int, int]] # IPv6 sockaddr + ] + ] ] class IO(threading.Thread): + """Handles asynchronous I/O for RabbitMQ connections.""" def __init__(self, host: str, @@ -33,12 +45,8 @@ def __init__(self, events: rabbitpy.events.Events, exceptions: queue.Queue, timeout: float = 0.01): - super().__init__(group=None, - target=None, - name='IO', - args=(), - kwargs={}, - daemon=True) + """Initialize the IO thread.""" + super().__init__(daemon=True, name='IO') self._events = events self._exceptions = exceptions self._host = host @@ -59,21 +67,14 @@ def __init__(self, self._remote_name: typing.Optional[str] = None self._socket: typing.Optional[socket.socket] = None self._write_buffer = collections.deque() - self._write_queue: queue.Queue = queue.Queue() def add_channel(self, channel: int, read_queue: queue.Queue) -> None: - """Add a channel to the channel queue dict for dispatching frames - to the channel. - - :param channel: The channel to add - :param read_queue: Queue for sending frames to the channel - - """ + """Associate a channel with a queue for frame dispatching.""" with self._lock: self._channels[int(channel)] = read_queue def close(self) -> None: - """Close the socket and set the proper event states""" + """Close the socket and update event states.""" with self._lock: self._events.clear(rabbitpy.events.SOCKET_OPENED) self._close_socket() @@ -81,88 +82,88 @@ def close(self) -> None: self._events.set(rabbitpy.events.SOCKET_CLOSED) def run(self) -> None: - """Run the IO thread, invoked by calling IO.start()""" + """Run the I/O loop.""" try: asyncio.run(self._run()) except asyncio.CancelledError: LOGGER.debug('IO thread cancelled') - self.close() except OSError as error: self._on_error(error) + finally: self.close() - def write_frame(self, channel: int, frame_value: base.Frame) -> None: + def write_frame(self, + channel: int, + frame_value: pamqp.base.Frame) -> None: """Write an AMQP frame to the socket.""" if not self.is_connected: - return self._add_exception( + self._add_exception( rabbitpy.exceptions.ConnectionException( self._host, self._port, 'Not connected')) + return with self._lock: - payload = frame.marshal(frame_value, channel) + payload = pamqp.frame.marshal(frame_value, channel) self._write_buffer.append(payload) @property def bytes_received(self) -> int: - """Return the number of bytes read/received from RabbitMQ""" + """Return the number of bytes received.""" with self._lock: return self._bytes_read @property def bytes_written(self) -> int: - """Return the number of bytes written to RabbitMQ""" + """Return the number of bytes written.""" with self._lock: return self._bytes_written @property - def is_connected(self): - """Returns True if the socket is connected""" + def is_connected(self) -> bool: + """Indicate socket connection status.""" with self._lock: return not self._closed.is_set() @property - def remote_name(self) -> str: - """Return the remote name of the socket""" + def remote_name(self) -> typing.Optional[str]: + """Return the remote socket name.""" with self._lock: return self._remote_name # Internal Methods def _add_exception(self, exc: Exception) -> None: - """Add an exception to the exception queue""" + """Add an exception to the exception queue and signal the event.""" LOGGER.debug('Adding exception %r', exc) self._exceptions.put(exc) self._events.set(rabbitpy.events.EXCEPTION_RAISED) def _close_socket(self) -> None: - """Close the socket, removing the FDs from the IOLoop""" - try: - self._ioloop.remove_reader(self._socket) - self._ioloop.remove_writer(self._socket) - except (AttributeError, IndexError): - pass - try: - self._socket.shutdown(socket.SHUT_RDWR) - self._socket.close() - except (AttributeError, OSError): - pass + """Close the socket and remove it from the I/O loop.""" + if self._socket is not None and self._ioloop is not None: + try: + self._ioloop.remove_reader(self._socket.fileno()) + self._ioloop.remove_writer(self._socket.fileno()) + except (AttributeError, IndexError, OSError): + pass + try: + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except (AttributeError, OSError): + pass + self._socket = None async def _connect(self) -> None: - """Connect to the RabbitMQ Server - - :raises: ConnectionException - - """ + """Establish a connection to the RabbitMQ server.""" sock = None - for (addr_family, sock_type, protocol, _cname, sock_addr) \ - in self._get_addr_info(): - LOGGER.debug('Attempting to connect to %r', sock_addr) + for addr_info in self._get_addr_info(): + LOGGER.debug('Attempting to connect to %r', addr_info[4]) try: - sock = self._create_socket(addr_family, sock_type, protocol) - sock.connect(sock_addr) + sock = self._create_socket(*addr_info[0:3]) + sock.connect(addr_info[4]) except OSError as error: sock.close() sock = None - LOGGER.debug('Error connecting to %r: %s', sock_addr, error) + LOGGER.debug('Error connecting to %r: %s', addr_info[4], error) continue else: break @@ -183,12 +184,7 @@ def _create_socket(self, sock_type: int, protocol: int) \ -> typing.Union[socket.socket, ssl.SSLSocket]: - """Create the new socket, optionally with SSL support. - - :param address_family: The address family to use when creating - :param sock_type: The type of socket to create - - """ + """Create a new socket, optionally wrapped with SSL.""" sock = socket.socket(address_family, sock_type, protocol) context = self._get_ssl_context() if context: @@ -196,6 +192,7 @@ def _create_socket(self, return sock def _get_addr_info(self) -> AddrInfo: + """Resolve hostname and port to address information.""" family = socket.AF_UNSPEC if socket.has_ipv6 else socket.AF_INET try: res = socket.getaddrinfo( @@ -206,7 +203,7 @@ def _get_addr_info(self) -> AddrInfo: return res def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: - """Return the configured SSLContext to use if needed""" + """Create and configure an SSL context if SSL is enabled.""" if self._use_ssl: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if self._ssl_options.get('check_hostname') is not None: @@ -221,7 +218,7 @@ def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: ssl_context.load_default_certs(ssl.Purpose.SERVER_AUTH) if self._ssl_options.get('certfile'): ssl_context.load_cert_chain( - self._ssl_options.get('certfile'), + self._ssl_options.get('certfile', ''), self._ssl_options.get('keyfile')) if self._ssl_options.get('verify') is not None: ssl_context.verify_mode = self._ssl_options['verify'] @@ -232,50 +229,46 @@ def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: def _on_data_received(value: bytes) \ -> tuple[bytes, typing.Optional[int], - typing.Optional[base.Frame], + typing.Optional[ + typing.Union[ + pamqp.base.Frame, + pamqp.body.ContentBody, + pamqp.header.ContentHeader, + pamqp.header.ProtocolHeader, + pamqp.heartbeat.Heartbeat]], int]: - """Get the pamqp frame from the value read from the socket. + """Unmarshal a pamqp frame from received socket data. - :param value: The value to parse for a pamqp frame - :return: Remainder of value, channel id, frame value, and bytes read + + :param value: The bytes received from the socket. + :return: Tuple containing remaining bytes, channel ID, + frame, and bytes consumed. """ if not value: return value, None, None, 0 try: - byte_count, channel_id, frame_in = frame.unmarshal(value) + byte_count, channel_id, frame_in = pamqp.frame.unmarshal(value) except (pamqp.exceptions.UnmarshalingException, ValueError): return value, None, None, 0 return value[byte_count:], channel_id, frame_in, byte_count def _on_error(self, exception: OSError) -> None: - """Common functions when a socket error occurs. Make sure to set closed - and add the exception, and note an exception event. - - :param exception: The socket error - - """ + """Handle socket errors, add exceptions, and signal events.""" if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): return - args = [self._host, self._port, str(exception)] - """ - if self._channels[0][0].open: - self._exceptions.put( - rabbitpy_exceptions.ConnectionResetException(*args)) - else: - """ - self._add_exception(rabbitpy.exceptions.ConnectionException(*args)) + self._add_exception(rabbitpy.exceptions.ConnectionException( + self._host, self._port, str(exception))) def _on_read_ready(self) -> None: - """Append the data that is read to the buffer and try and parse - frames out of it. - - """ + """Read data from the socket and process any complete frames.""" with self._lock: self._buffer += self._socket.recv(32768) while self._buffer: remaining, channel_id, frame_value, bytes_read =\ self._on_data_received(self._buffer) + if channel_id is None: + raise RuntimeError('Invalid channel ID received') if remaining == self._buffer: break self._buffer = remaining @@ -283,36 +276,38 @@ def _on_read_ready(self) -> None: self._channels[channel_id].put(frame_value) def _on_write_ready(self) -> None: - """Write the next frame to the socket""" - with self._lock: - try: + """Write pending data from the write buffer to the socket.""" + try: + with self._lock: payload = self._write_buffer.popleft() - except IndexError: - return - try: - self._socket.sendall(payload) - except socket.timeout: - LOGGER.warning('Timed out writing %i bytes to socket', - len(payload)) + except IndexError: + return + try: + self._socket.sendall(payload) + except socket.timeout: + LOGGER.warning('Timed out writing %i bytes to socket', + len(payload)) + with self._lock: self._write_buffer.appendleft(payload) - except OSError as error: + except OSError as error: + with self._lock: self._write_buffer.appendleft(payload) - if error.errno == 35: - LOGGER.debug('socket resource temp unavailable') - else: - self._on_error(error) + if error.errno == 35: + LOGGER.debug('socket resource temp unavailable') else: + self._on_error(error) + else: + with self._lock: self._bytes_written += len(payload) async def _run(self): - """Core logic that connects and blocks until the socket is closed""" + """Manage the I/O loop: connect, read, write, and handle closure.""" self._ioloop = asyncio.get_running_loop() - with self._lock: await self._connect() if not self._socket: - return + return # Could not establish connection self._ioloop.add_reader(self._socket, self._on_read_ready) self._ioloop.add_writer(self._socket, self._on_write_ready) diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index 6b7906d..207f5a6 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -25,7 +25,7 @@ } -def parse(url: str = DEFAULT_URL) -> dict: +def parse(url: typing.Optional[str] = DEFAULT_URL) -> dict: """Parse the AMQP URL passed in and return the configuration information in a dictionary of values. @@ -93,7 +93,9 @@ def parse(url: str = DEFAULT_URL) -> dict: vhost = parsed.path[1:] or DEFAULT_VHOST # Parse the query string - query_args = urllib.parse.parse_qs(parsed.query) + query_args = urllib.parse.parse_qs( + parsed.query.decode('utf-8') if isinstance(parsed.query, bytes) + else parsed.query) # Return the configuration dictionary to use when connecting return { @@ -109,18 +111,18 @@ def parse(url: str = DEFAULT_URL) -> dict: 'frame_max', query_args, constants.FRAME_MAX_SIZE), 'channel_max': _query_args_int( 'channel_max', query_args, DEFAULT_CHANNEL_MAX), - 'locale': _query_args_value('locale', query_args), + 'locale': _query_args_str('locale', query_args), 'ssl': use_ssl, 'ssl_options': { - 'check_hostname': _query_args_value( + 'check_hostname': _query_args_str( 'ssl_check_hostname', query_args, '').lower() not in ('0', 'false', 'no'), - 'cafile': _query_args_mk_value( + 'cafile': _query_args_multi_key_value( ['cacertfile', 'ssl_cacert', 'cafile'], query_args), - 'capath': _query_args_value('capath', query_args), - 'certfile': _query_args_mk_value( + 'capath': _query_args_str('capath', query_args), + 'certfile': _query_args_multi_key_value( ['certfile', 'ssl_cert'], query_args), - 'keyfile': _query_args_mk_value( + 'keyfile': _query_args_multi_key_value( ['keyfile', 'ssl_key'], query_args), 'verify': _query_args_ssl_validation(query_args) } @@ -139,22 +141,9 @@ def _query_args_int(key: str, values: dict, default: int) -> int: return int(values.get(key, [default])[0]) -def _query_args_float(key: str, values: dict, default: float) -> float: - """Return the query arg value as a float for the specified key or - return the specified default value. - - :param key: The key to return the value for - :param values: The query value dict returned by urlparse - :param default: The default return value - - """ - return float(values.get(key, [default])[0]) - - -def _query_args_value( +def _query_args_str( key: str, values: dict, - default: typing.Union[int, float, str, None] = None) \ - -> typing.Union[int, float, str, None]: + default: typing.Optional[str] = None) -> typing.Optional[str]: """Return the value from the query arguments for the specified key or the default value. @@ -165,7 +154,7 @@ def _query_args_value( return values.get(key, [default])[0] -def _query_args_mk_value(keys: list[str], values: dict) \ +def _query_args_multi_key_value(keys: list[str], values: dict) \ -> typing.Union[int, float, str, None]: """Try and find the query string value where the value can be specified with different keys. @@ -175,7 +164,7 @@ def _query_args_mk_value(keys: list[str], values: dict) \ """ for key in keys: - value = _query_args_value(key, values) + value = _query_args_str(key, values) if value is not None: return value return None @@ -189,7 +178,8 @@ def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: :param values: The dict of query values from the AMQP URI """ - validation = _query_args_mk_value(['verify', 'ssl_validation'], values) + validation = _query_args_multi_key_value( + ['verify', 'ssl_validation'], values) if not validation: return None elif validation not in SSL_CERT_MAP: @@ -198,7 +188,7 @@ def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: return SSL_CERT_MAP[validation] -def _validate_uri_scheme(scheme: str) -> None: +def _validate_uri_scheme(scheme: typing.Union[bytes, str]) -> None: """Ensure that the specified URI scheme is supported by rabbitpy :param scheme: The value to validate From 756c7e6743bd28e308a2ac286e29f4ff75de012f Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Thu, 9 Jan 2025 19:12:13 -0500 Subject: [PATCH 35/59] Simplify and standardize pyproject.toml formatting Condensed array and dictionary formatting for consistency and readability. Added Pyright configuration for improved static type checking and excluded tests/examples from analysis. --- pyproject.toml | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 751994a..fce64eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "A pure python, thread-safe, minimalistic and pythonic RabbitMQ cl readme = "README.rst" requires-python = ">=3.9" license = { file = "LICENSE" } -authors = [ { name = "Gavin M. Roy", email = "gavinmroy@gmail.com" } ] +authors = [{ name = "Gavin M. Roy", email = "gavinmroy@gmail.com" }] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -21,20 +21,12 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Communications", "Topic :: Internet", - "Topic :: Software Development :: Libraries" -] -dependencies = [ - "pamqp>=3.0,<4.0", + "Topic :: Software Development :: Libraries", ] +dependencies = ["pamqp>=3.0,<4.0"] [project.optional-dependencies] -dev = [ - "codeclimate-test-reporter", - "codecov", - "coverage[toml]", - "mock", - "ruff" -] +dev = ["codeclimate-test-reporter", "codecov", "coverage[toml]", "mock", "ruff"] [project.urls] Documentation = "https://rabbitpy.readthedocs.io" @@ -47,22 +39,21 @@ target-version = "py39" [tool.ruff.lint] select = [ - "ASYNC", # flake8-async - "B", # flake8-bugbear - "BLE", # flake8-blind-except - "C4", # flake8-comprehensions - "DTZ", # flake8-datetimez - "E", "W", # pycodestyle - "F", # pyflakes - "G", # flake8-logging-format - "I", # isort - "S", # flake8-bandit - "T20", # flake8-print - "UP", # pyupgrade -] -exclude = [ - "examples/*.py" + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "E", + "W", # pycodestyle + "F", # pyflakes + "G", # flake8-logging-format + "I", # isort + "S", # flake8-bandit + "T20", # flake8-print + "UP", # pyupgrade ] +exclude = ["examples/*.py"] [tool.coverage.xml] output = "build/coverage.xml" @@ -71,3 +62,8 @@ output = "build/coverage.xml" branch = true source = ["rabbitpy"] command_line = "-m unittest discover tests --buffer --verbose" + +[tool.pyright] +reportOptionalMemberAccess = false +reportUnknownMemberType = false +ignore = ["**/tests/**", "**/examples/**"] From f2356689606f63449365d6c0f0c9d9e6e7b769f9 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Fri, 10 Jan 2025 14:12:02 -0500 Subject: [PATCH 36/59] Updated client cert --- tests/data/client.crt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/data/client.crt b/tests/data/client.crt index 68d6aba..9a46892 100644 --- a/tests/data/client.crt +++ b/tests/data/client.crt @@ -1,8 +1,8 @@ -----BEGIN CERTIFICATE----- -MIID3DCCAsSgAwIBAgIUEKZgSjTCJpZeDvL8g96+15+lmzcwDQYJKoZIhvcNAQEL +MIID3DCCAsSgAwIBAgIUY8MyAULCRmoy5f/fuOABgZHzS+gwDQYJKoZIhvcNAQEL BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE -AwwHVGVzdCBDQTAeFw0yNTAxMDcxODU4NDRaFw0yNjAxMDcxODU4NDRaMHMxCzAJ +AwwHVGVzdCBDQTAeFw0yNTAxMDcyMTE5NTJaFw0zNTAxMDUyMTE5NTJaMHMxCzAJ BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE CgwMT3JnYW5pemF0aW9uMREwDwYDVQQLDAhPcmcgVW5pdDEbMBkGA1UEAwwSY2xp ZW50LmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA @@ -14,10 +14,10 @@ HtVGz9ft048cxAzegtDZUxcs7HVKvAB5Ysinp5tEvIq0qXd61jQU5/VORZYMowAp 7lM0KE7CQBuN6Ea2igit/wIDAQABo3UwczAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB /wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUe1HIoD6aZ0EI 0tCWzvrSieUUF2UwHwYDVR0jBBgwFoAUeEq1d66tEUANt0pCVu+zpk+8004wDQYJ -KoZIhvcNAQELBQADggEBACoiwOTzkmEPQZVDncGfBvA/2H/FCLnKD62QbgxarLns -+lFax0n1Qc4d/xDbV/xXE2eqKnHEsL9hMDohKGFQlfqY0OwLGke14B25KuZrZuem -hzMKAdyhLzFaQx3tc2r4tSeOOLTKw0lu3rq1bLjL2Q63v1ogMYKQFZvqQYWFAj5R -1csYUmW5SbcCeHOjiWNtjs8u+1hv76U4W2WV54mzV0o1wWuyDzrgk/Zq8GcTSnKv -u8gf6ueXMmiSM2G66SahA5pLtm8wbocjTBtWvZ2sIqh0g7TD7Czr1fDazRw4ZiH8 -npd4MhX6vje+cmCB0exvNSbMS/497yQGf1kL/8cZseg= +KoZIhvcNAQELBQADggEBABYY0HhAxQDdv5cjxxpjZJK7e2G9w2xX9y7Afe1uas5G +dkJ4WlWZAP5m7rS6adYGeBzQQxCJPL2bnrwvurAhLjJNCM+SlSdElcjRm362DDya +ZFPDoa0L2VQT7UxVjmnHAQPOlYxMZt8b1fw+LYMttyP48/G1PJVkqcNIlZXbAC6K +fOQhcYs3OratCgcYUPFBIq1KuDgOQsnMpJoEeEBzysl65z8LIKcOPIaEXB7S1CAw +qYU0u2Bi8jooCdaaHkBvKF/ujQYW2UyzUvbeVjY2uxF6bVnHloyNS34V7Y7kL4ea +V+2NUW74kgKjTaHuFx6CP9i/i6dIBh28xe4Zg//hnX8= -----END CERTIFICATE----- From cd478a2a8f4890d0231a05b9279c7c679403c11d Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 20:34:55 -0400 Subject: [PATCH 37/59] Modernize project tooling to current standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch to hatchling build backend - Add uv.lock with UV_CONFIG_FILE=/dev/null (no internal PyPI refs) - Replace optional-dependencies dev with dependency-groups - Update Python matrix to 3.11–3.14 (drop 3.9, 3.10) - Add pre-commit with ruff-format and ruff hooks - Replace test.yaml CI with testing.yaml using uv + RabbitMQ service - Simplify deploy.yaml to use uv build + trusted publishing - Add CLAUDE.md with development workflow docs - Remove MANIFEST.in (hatchling doesn't need it) - Apply ruff format and fix across all source files - Add ClassVar annotations, fix DTZ005, UP007, RUF012 Co-Authored-By: Gavin M. Roy --- .github/workflows/deploy.yaml | 66 +-- .github/workflows/test.yaml | 46 -- .github/workflows/testing.yaml | 69 +++ .gitignore | 1 + .pre-commit-config.yaml | 19 + CLAUDE.md | 29 ++ MANIFEST.in | 1 - docs/api/exceptions.rst | 1 - docs/conf.py | 233 +--------- docs/threads.rst | 1 - examples/consumer.py | 1 + examples/getter.py | 2 +- examples/publisher.py | 27 +- examples/transactional_publisher.py | 22 +- pyproject.toml | 100 +++-- rabbitpy/__init__.py | 66 +-- rabbitpy/amqp.py | 455 ------------------- rabbitpy/amqp_queue.py | 373 ---------------- rabbitpy/base.py | 452 ------------------- rabbitpy/channel.py | 481 -------------------- rabbitpy/channel0.py | 264 +---------- rabbitpy/connection.py | 632 ++++----------------------- rabbitpy/events.py | 38 +- rabbitpy/exceptions.py | 24 +- rabbitpy/exchange.py | 187 -------- rabbitpy/heartbeat.py | 68 --- rabbitpy/io.py | 123 +++--- rabbitpy/message.py | 376 ---------------- rabbitpy/queue.py | 0 rabbitpy/simple.py | 298 ------------- rabbitpy/state.py | 68 +++ rabbitpy/tx.py | 112 ----- rabbitpy/url_parser.py | 58 ++- rabbitpy/utils.py | 71 --- tests/base_tests.py | 37 -- tests/channel_test.py | 64 --- tests/events_tests.py | 96 ---- tests/helpers.py | 31 -- tests/integration/test_connection.py | 28 ++ tests/integration_tests.py | 533 ---------------------- tests/mixins.py | 4 +- tests/test_amqp.py | 20 - tests/test_events.py | 14 +- tests/test_exchange.py | 93 ---- tests/test_io.py | 94 ++-- tests/test_message.py | 506 --------------------- tests/test_queue.py | 429 ------------------ tests/test_tx.py | 90 ---- tests/test_url_parser.py | 1 - tests/utils_tests.py | 67 --- uv.lock | 416 ++++++++++++++++++ 51 files changed, 1059 insertions(+), 6228 deletions(-) delete mode 100644 .github/workflows/test.yaml create mode 100644 .github/workflows/testing.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 CLAUDE.md delete mode 100644 MANIFEST.in delete mode 100644 rabbitpy/amqp.py delete mode 100644 rabbitpy/amqp_queue.py delete mode 100644 rabbitpy/base.py delete mode 100644 rabbitpy/exchange.py delete mode 100644 rabbitpy/heartbeat.py delete mode 100644 rabbitpy/message.py create mode 100644 rabbitpy/queue.py delete mode 100644 rabbitpy/simple.py create mode 100644 rabbitpy/state.py delete mode 100644 rabbitpy/tx.py delete mode 100644 rabbitpy/utils.py delete mode 100644 tests/base_tests.py delete mode 100644 tests/channel_test.py delete mode 100644 tests/events_tests.py delete mode 100644 tests/helpers.py create mode 100644 tests/integration/test_connection.py delete mode 100644 tests/integration_tests.py delete mode 100644 tests/test_amqp.py delete mode 100644 tests/test_exchange.py delete mode 100644 tests/test_message.py delete mode 100644 tests/test_queue.py delete mode 100644 tests/test_tx.py delete mode 100644 tests/utils_tests.py create mode 100644 uv.lock diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index aabd16e..ddc0945 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,53 +1,31 @@ -name: Deployment +name: Publish to PyPI + on: - push: - branches-ignore: ["*"] - tags: ["*"] + release: + types: [published] + jobs: - build: - name: Build distribution + deploy: runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Install pypa/build - run: python3 -m pip install build --user - - - name: Build a binary wheel and a source tarball - run: python3 -m build - - - name: Store the distribution packages - uses: actions/upload-artifact@v3 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - publish-to-pypi: - name: Publish Python distribution to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/rabbitpy + url: https://pypi.org/project/rabbitpy/ + permissions: + contents: read id-token: write + steps: - - name: Download all the dists - uses: actions/download-artifact@v4.1.7 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Build and check package + run: | + uv build + uvx twine check dist/* + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 316019f..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Testing -on: - push: - branches: ["*"] - paths-ignore: - - 'docs/**' - - '*.rst' - tags-ignore: ["*"] -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 5 - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - env: - TEST_HOST: docker - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install testing dependencies - run: pip install -e '.[dev]' - - - name: Bootstrap - run: ./bootstrap.sh - - - name: Run unit tests - run: coverage run - - - name: Generate coverage report - run: coverage report && coverage xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - fail_ci_if_error: true - file: ./build/coverage.xml - flags: unittests - name: codecov-umbrella - token: ${{secrets.CODECOV_TOKEN}} diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..c2a0104 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,69 @@ +name: Testing +on: + pull_request: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + tags-ignore: ["*"] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + + services: + rabbitmq: + image: rabbitmq:4-management + ports: + - 5672:5672 + - 15672:15672 + options: >- + --health-cmd "rabbitmq-diagnostics -q check_running" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python }} + + - name: Install dependencies + run: uv sync --all-groups + + - name: Create build directory and test env + run: | + mkdir -p build + cat > build/test.env < v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. htmlhelp_basename = 'rabbitpydoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'rabbitpy.tex', u'rabbitpy Documentation', - u'Gavin M. Roy', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'rabbitpy', u'rabbitpy Documentation', - [u'Gavin M. Roy'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'rabbitpy', u'rabbitpy Documentation', - u'Gavin M. Roy', 'rabbitpy', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' diff --git a/docs/threads.rst b/docs/threads.rst index 47b98b5..cc387d0 100644 --- a/docs/threads.rst +++ b/docs/threads.rst @@ -92,4 +92,3 @@ then spawns a thread for publishing and a thread for consuming. # Join the consumer thread, waiting for it to consume all MESSAGE_COUNT messages consumer_thread.join() - diff --git a/examples/consumer.py b/examples/consumer.py index abe0022..b20c136 100644 --- a/examples/consumer.py +++ b/examples/consumer.py @@ -1,5 +1,6 @@ #!/usr/bin/env python import logging + import rabbitpy URL = 'amqp://guest:guest@localhost:5672/%2f?heartbeat=15' diff --git a/examples/getter.py b/examples/getter.py index a537efc..19b82bc 100644 --- a/examples/getter.py +++ b/examples/getter.py @@ -8,4 +8,4 @@ message = queue.get() message.pprint(True) message.ack() - print('There are {} more messages in the queue'.format(len(queue))) \ No newline at end of file + print(f'There are {len(queue)} more messages in the queue') diff --git a/examples/publisher.py b/examples/publisher.py index b99a19c..43c6fdc 100644 --- a/examples/publisher.py +++ b/examples/publisher.py @@ -1,16 +1,16 @@ #!/usr/bin/env python -import rabbitpy -import logging import datetime +import logging import uuid + +import rabbitpy + logging.basicConfig(level=logging.DEBUG) # Use a new connection as a context manager with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - # Use the channel as a context manager with conn.channel() as channel: - # Create the exchange exchange = rabbitpy.Exchange(channel, 'example_exchange') exchange.declare() @@ -23,14 +23,17 @@ queue.bind(exchange, 'test-routing-key') # Create the msg by passing channel, message and properties (as a dict) - message = rabbitpy.Message(channel, - 'Lorem ipsum dolor sit amet, consectetur ' - 'adipiscing elit.', - {'content_type': 'text/plain', - 'delivery_mode': 1, - 'message_type': 'Lorem ipsum', - 'timestamp': datetime.datetime.now(), - 'message_id': uuid.uuid1()}) + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'delivery_mode': 1, + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': uuid.uuid1(), + }, + ) # Publish the message message.publish(exchange, 'test-routing-key') diff --git a/examples/transactional_publisher.py b/examples/transactional_publisher.py index 9cfa0eb..ad6640e 100644 --- a/examples/transactional_publisher.py +++ b/examples/transactional_publisher.py @@ -1,11 +1,11 @@ #!/usr/bin/env python -import rabbitpy import datetime import uuid +import rabbitpy + with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: with conn.channel() as channel: - # Create the exchange exchange = rabbitpy.Exchange(channel, 'example_exchange') exchange.declare() @@ -22,14 +22,16 @@ tx.select() # Create the message - message = rabbitpy.Message(channel, - 'Lorem ipsum dolor sit amet, consectetur ' - 'adipiscing elit.', - {'content_type': 'text/plain', - 'message_type': 'Lorem ipsum', - 'timestamp': datetime.datetime.now(), - 'message_id': uuid.uuid1() - }) + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': uuid.uuid1(), + }, + ) # Publish the message message.publish(exchange, 'test-routing-key') diff --git a/pyproject.toml b/pyproject.toml index fce64eb..f17d229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,69 +1,107 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "rabbitpy" version = "3.0.0" description = "A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library" readme = "README.rst" -requires-python = ">=3.9" -license = { file = "LICENSE" } -authors = [{ name = "Gavin M. Roy", email = "gavinmroy@gmail.com" }] +requires-python = ">=3.11" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +authors = [ + {name = "Gavin M. Roy", email = "gavinmroy@gmail.com"} +] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", + "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Communications", "Topic :: Internet", "Topic :: Software Development :: Libraries", + "Typing :: Typed", ] +keywords = ["rabbitmq", "amqp", "messaging"] dependencies = ["pamqp>=3.0,<4.0"] -[project.optional-dependencies] -dev = ["codeclimate-test-reporter", "codecov", "coverage[toml]", "mock", "ruff"] - [project.urls] +Homepage = "https://github.com/gmr/rabbitpy" Documentation = "https://rabbitpy.readthedocs.io" -Repository = "https://github.com/gmr/rabbitpy.git" -"Code Coverage" = "https://app.codecov.io/github/gmr/rabbitpy" + +[dependency-groups] +dev = [ + "build", + "coverage[toml]", + "pre-commit", + "ruff", +] + +[tool.coverage.run] +branch = true +command_line = "-m unittest discover tests --buffer --verbose" +data_file = "build/.coverage" +source = ["rabbitpy"] + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "typing.TYPE_CHECKING", +] + +[tool.coverage.html] +directory = "build/coverage" + +[tool.coverage.xml] +output = "build/coverage.xml" + +[tool.hatch.build.targets.wheel] +include = ["rabbitpy"] [tool.ruff] line-length = 79 -target-version = "py39" +target-version = "py311" + +[tool.ruff.format] +quote-style = "single" [tool.ruff.lint] select = [ - "ASYNC", # flake8-async - "B", # flake8-bugbear "BLE", # flake8-blind-except "C4", # flake8-comprehensions - "DTZ", # flake8-datetimez - "E", - "W", # pycodestyle + "C90", # mccabe + "E", "W", # pycodestyle "F", # pyflakes "G", # flake8-logging-format "I", # isort + "N", # pep8-naming + "Q", # flake8-quotes "S", # flake8-bandit + "ASYNC", # flake8-async + "B", # flake8-bugbear + "DTZ", # flake8-datetimez + "FURB", # refurb + "RUF", # ruff-specific "T20", # flake8-print "UP", # pyupgrade ] -exclude = ["examples/*.py"] - -[tool.coverage.xml] -output = "build/coverage.xml" +ignore = [ + "N818", # exception class names not required to end in Error + "RSE", # contradicts Python Style Guide + "UP040", # allow non-PEP695 type alias syntax +] +flake8-quotes = {inline-quotes = "single"} -[tool.coverage.run] -branch = true -source = ["rabbitpy"] -command_line = "-m unittest discover tests --buffer --verbose" +[tool.ruff.lint.mccabe] +max-complexity = 15 -[tool.pyright] -reportOptionalMemberAccess = false -reportUnknownMemberType = false -ignore = ["**/tests/**", "**/examples/**"] +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["N802", "S"] +"examples/**/*.py" = ["S", "T20"] diff --git a/rabbitpy/__init__.py b/rabbitpy/__init__.py index d3a6942..4a59711 100644 --- a/rabbitpy/__init__.py +++ b/rabbitpy/__init__.py @@ -1,67 +1,9 @@ """ -rabbitpy, a pythonic RabbitMQ client +rabbitpy, An opinionated and Pythonic RabbitMQ client """ -__version__ = '3.0.0' - -import logging - -from rabbitpy.amqp import AMQP -from rabbitpy.connection import Connection -from rabbitpy.channel import Channel -from rabbitpy.exchange import Exchange -from rabbitpy.exchange import DirectExchange -from rabbitpy.exchange import FanoutExchange -from rabbitpy.exchange import HeadersExchange -from rabbitpy.exchange import TopicExchange -from rabbitpy.message import Message -from rabbitpy.amqp_queue import Queue -from rabbitpy.tx import Tx -from rabbitpy.simple import SimpleChannel -from rabbitpy.simple import consume -from rabbitpy.simple import get -from rabbitpy.simple import publish -from rabbitpy.simple import create_queue -from rabbitpy.simple import delete_queue -from rabbitpy.simple import create_direct_exchange -from rabbitpy.simple import create_fanout_exchange -from rabbitpy.simple import create_headers_exchange -from rabbitpy.simple import create_topic_exchange -from rabbitpy.simple import delete_exchange - -logging.getLogger('rabbitpy').addHandler(logging.NullHandler()) +__version__ = '3.0.0' +__author__ = 'Gavin M. Roy' -__all__ = [ - '__version__', - 'amqp_queue', - 'channel', - 'connection', - 'exceptions', - 'exchange', - 'message', - 'simple', - 'tx', - 'AMQP', - 'Connection', - 'Channel', - 'SimpleChannel', - 'Exchange', - 'DirectExchange', - 'FanoutExchange', - 'HeadersExchange', - 'TopicExchange', - 'Message', - 'Queue', - 'Tx', - 'consume', - 'get', - 'publish', - 'create_queue', - 'delete_queue', - 'create_direct_exchange', - 'create_fanout_exchange', - 'create_headers_exchange', - 'create_topic_exchange', - 'delete_exchange' -] +__all__ = ['__author__', '__version__'] diff --git a/rabbitpy/amqp.py b/rabbitpy/amqp.py deleted file mode 100644 index 7138963..0000000 --- a/rabbitpy/amqp.py +++ /dev/null @@ -1,455 +0,0 @@ -""" -AMQP Adapter - -""" -import typing - -from pamqp import commands - -from rabbitpy import base, channel, message, exceptions, 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(AMQP, self).__init__(rabbitpy_channel) - self.consumer_tag = 'rabbitpy.%s.%s' % (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: typing.Optional[dict] = 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: typing.Optional[dict, bytes] = None, - mandatory: bool = False, - immediate: bool = False) \ - -> typing.Union[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: typing.Optional[dict] = 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: typing.Optional[dict] = 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: typing.Optional[dict] = 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: typing.Optional[dict] = 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: typing.Optional[dict] = 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: typing.Optional[dict] = 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 deleted file mode 100644 index 3163fde..0000000 --- a/rabbitpy/amqp_queue.py +++ /dev/null @@ -1,373 +0,0 @@ -""" -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, channel as chan, exceptions, exchange, message, - utils) - -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 = dict() - 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: typing.Optional[int] = None, - message_ttl: typing.Optional[int] = None, - expires: typing.Optional[int] = None, - dead_letter_exchange: typing.Optional[str] = None, - dead_letter_routing_key: typing.Optional[str] = None, - arguments: typing.Optional[dict] = 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. - - """ - super(Queue, self).__init__(channel, name) - - # Defaults - self.consumer_tag = 'rabbitpy.%s.%s' % (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(Queue, self).__setattr__(name, value) - - def bind(self, - source: exchange.ExchangeTypes, - routing_key: typing.Optional[str] = None, - arguments: typing.Optional[dict] = 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: typing.Optional[int] = None, - priority: typing.Optional[int] = None, - consumer_tag: typing.Optional[str] = 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) -> typing.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) \ - -> typing.Union[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: typing.Optional[typing.List[str]] = None) \ - -> typing.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: typing.Optional[str] = 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: typing.Optional[int] = None, - priority: typing.Optional[int] = 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 deleted file mode 100644 index 3072f25..0000000 --- a/rabbitpy/base.py +++ /dev/null @@ -1,452 +0,0 @@ -""" -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 (connection as conn, channel as chan, exceptions, message, - utils) - -LOGGER = logging.getLogger(__name__) - -Interrupt = typing.TypedDict( - 'Interrupt', { - 'event': threading.Event, - 'callback': typing.Optional[typing.Callable], - 'args': typing.Optional[typing.Iterable[typing.Any]] - }) - -FrameTypes = typing.Union[str, typing.Type[base.Frame], - typing.List[typing.Union[str, - typing.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) \ - -> typing.Union[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(AMQPClass, self).__init__(channel) - if not isinstance(channel, chan.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(StatefulObject, self).__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('Invalid state value: %r' % value) - 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(AMQPChannel, self).__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: typing.Optional[int] = None - self._connection: conn.Connection = connection - self._exceptions: queue.Queue = exception_queue - self._read_queue: typing.Optional[queue.Queue] = None - self._waiting: bool = False - self._write_lock = threading.Lock() - self._write_queue: typing.Optional[queue.Queue] = 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) \ - -> typing.Union[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: typing.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) \ - -> typing.Union[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 socket.error: - 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) \ - -> typing.Union[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 d336db5..e69de29 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -1,481 +0,0 @@ -""" -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 -import typing - -from pamqp import base as pamqp_base, commands, header - -from rabbitpy import (amqp, amqp_queue, base, connection as conn, events as - rabbitpy_events, exceptions, message) - -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(Channel, self).__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: typing.Optional[typing.Type[BaseException]], - exc_val: typing.Optional[BaseException], - unused_exc_tb: typing.Optional[types.TracebackType]): - """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: typing.Union[amqp.AMQP, amqp_queue.Queue], - consumer_tag: typing.Optional[str] = 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(Channel, self).close() - - def consume_message(self) -> typing.Union[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: typing.Optional[int] = 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 = dict() - 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(Channel, self)._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: typing.Optional[pamqp_base.Frame], - header_frame: typing.Optional[header.ContentHeader], - body: typing.Optional[typing.Union[str, bytes]]) \ - -> typing.Union[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 dict() - msg = message.Message(self, body, props) - msg.method = method_frame - msg.name = method_frame.name - return msg - - def _get_from_read_queue(self) -> typing.Union[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) -> typing.Union[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: typing.Union[commands.Basic.Deliver, - commands.Basic.Get, - commands.Basic.Return]) \ - -> typing.Union[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/channel0.py b/rabbitpy/channel0.py index bf7087c..821c758 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -1,267 +1,9 @@ -""" -Channel0 is used for connection level communication between RabbitMQ and the -client on channel 0. - -""" -import locale import logging import queue -import socket -import sys -import typing - -from pamqp import base as pamqp_base, commands, header, heartbeat - -from rabbitpy import __version__, connection as conn, base, events, exceptions LOGGER = logging.getLogger(__name__) -DEFAULT_LOCALE = locale.getdefaultlocale() -del locale - - -class Channel0(base.AMQPChannel): - """Channel0 is used to negotiate a connection with RabbitMQ and for - processing and dispatching events on channel 0 once connected. - - :param connection_args: Data required to negotiate the connection - :param events_obj: The shared events coordination object - :param exception_queue: The queue where any pending exceptions live - :param write_queue: The queue to place data to write in - :param write_trigger: The socket to write to, to trigger IO writes - - """ - CHANNEL = 0 - - CLOSE_REQUEST_FRAME = commands.Connection.Close - DEFAULT_LOCALE = 'en-US' - - def __init__(self, - connection_args: dict, - events_obj: events.Events, - exception_queue: queue.Queue, - write_queue: queue.Queue, - write_trigger: socket.socket, - connection: conn.Connection): - super(Channel0, self).__init__(exception_queue, write_trigger, - connection) - self._channel_id = 0 - self._args = connection_args - self._events = events_obj - self._exceptions = exception_queue - self._read_queue = queue.Queue() - self._write_queue = write_queue - self._write_trigger = write_trigger - self._state = self.CLOSED - self._max_channels = connection_args['channel_max'] - self._max_frame_size = connection_args['frame_max'] - self._heartbeat_interval = connection_args['heartbeat'] - self.properties: typing.Optional[dict] = None - - def close(self): - """Close the connection via Channel0 communication.""" - if self.open: - self._set_state(self.CLOSING) - self.rpc(commands.Connection.Close()) - - @property - def heartbeat_interval(self): - """Return the AMQP heartbeat interval for the connection - - :rtype: int - - """ - return self._heartbeat_interval - - @property - def maximum_channels(self): - """Return the AMQP maximum channel count for the connection - - :rtype: int - - """ - return self._max_channels - - @property - def maximum_frame_size(self): - """Return the AMQP maximum frame size for the connection - - :rtype: int - - """ - return self._max_frame_size - - def on_frame(self, value: typing.Union[commands.Connection.Blocked, - commands.Connection.Close, - commands.Connection.Start, - commands.Connection.Tune, - pamqp_base.Frame]) -> None: - """Process an RPC frame received from the server""" - LOGGER.debug('Received frame: %r', value.name) - if value.name == 'Connection.Close': - LOGGER.warning('RabbitMQ closed the connection (%s): %s', - value.reply_code, value.reply_text) - self._set_state(self.CLOSED) - self._events.set(events.SOCKET_CLOSED) - self._events.set(events.CHANNEL0_CLOSED) - self._connection.close() - if value.reply_code in exceptions.AMQP: - err = exceptions.AMQP[value.reply_code](value.reply_text) - else: - err = exceptions.RemoteClosedException(value.reply_code, - value.reply_text) - self._exceptions.put(err) - self._trigger_write() - elif value.name == 'Connection.Blocked': - LOGGER.warning('RabbitMQ has blocked the connection: %s', - value.reason) - self._events.set(events.CONNECTION_BLOCKED) - elif value.name == 'Connection.CloseOk': - self._set_state(self.CLOSED) - self._events.set(events.CHANNEL0_CLOSED) - elif value.name == 'Connection.OpenOk': - self._on_connection_open_ok() - elif value.name == 'Connection.Start': - self._on_connection_start(value) - elif value.name == 'Connection.Tune': - self._on_connection_tune(value) - elif value.name == 'Connection.Unblocked': - LOGGER.info('Connection is no longer blocked') - self._events.clear(events.CONNECTION_BLOCKED) - elif value.name == 'Heartbeat': - pass - else: - LOGGER.warning('Unexpected Channel0 Frame: %r', value) - raise commands.AMQPUnexpectedFrame(value) - - def send_heartbeat(self) -> None: - """Send a heartbeat frame to the remote connection.""" - self.write_frame(heartbeat.Heartbeat()) - - def start(self) -> None: - """Start the AMQP protocol negotiation""" - self._set_state(self.OPENING) - self._write_protocol_header() - - def _build_open_frame(self) -> commands.Connection.Open: - """Build and return the Connection.Open frame.""" - return commands.Connection.Open(self._args['virtual_host']) - - def _build_start_ok_frame(self) -> commands.Connection.StartOk: - """Build and return the Connection.StartOk frame.""" - properties = { - 'product': 'rabbitpy', - 'platform': 'Python {0}.{1}.{2}'.format(*sys.version_info), - 'capabilities': { - 'authentication_failure_close': True, - 'basic.nack': True, - 'connection.blocked': True, - 'consumer_cancel_notify': True, - 'publisher_confirms': True - }, - 'information': 'See https://rabbitpy.readthedocs.io', - 'version': __version__ - } - return commands.Connection.StartOk( - client_properties=properties, - response=self._credentials, - locale=self._get_locale()) - - def _build_tune_ok_frame(self) -> commands.Connection.TuneOk: - """Build and return the Connection.TuneOk frame.""" - return commands.Connection.TuneOk( - self._max_channels, self._max_frame_size, self._heartbeat_interval) - - @property - def _credentials(self) -> str: - """Return the marshaled credentials for the AMQP connection.""" - return '\0%s\0%s' % (self._args['username'], self._args['password']) - - def _get_locale(self) -> str: - """Return the current locale for the python interpreter or the default - locale. - - """ - if not self._args['locale']: - return DEFAULT_LOCALE[0] or self.DEFAULT_LOCALE - return self._args['locale'] - - @staticmethod - def _negotiate(client_value: int, server_value: int) -> int: - """Return the negotiated value between what the client has requested - and the server has requested for how the two will communicate. - - :param client_value: - :param server_value: - - """ - return (min(client_value, server_value) or - (client_value or server_value)) - - def _on_connection_open_ok(self) -> None: - LOGGER.debug('Connection opened') - self._set_state(self.OPEN) - self._events.set(events.CHANNEL0_OPENED) - - def _on_connection_start(self, value: commands.Connection.Start) -> None: - """Negotiate the Connection.Start process, writing out a - Connection.StartOk frame when the Connection.Start frame is received. - - :raises: rabbitpy.exceptions.ConnectionException - - """ - if not self._validate_connection_start(value): - LOGGER.error('Could not negotiate a connection, disconnecting') - raise exceptions.ConnectionResetException() - - self.properties = value.server_properties - for key in self.properties: - if key == 'capabilities': - for capability in self.properties[key]: - LOGGER.debug('Server supports %s: %r', capability, - self.properties[key][capability]) - else: - LOGGER.debug('Server %s: %r', key, self.properties[key]) - self.write_frame(self._build_start_ok_frame()) - - def _on_connection_tune(self, value: commands.Connection.Tune) -> None: - """Negotiate the Connection.Tune frames, waiting for the - Connection.Tune frame from RabbitMQ and sending the Connection.TuneOk - frame. - - """ - self._max_frame_size = self._negotiate( - self._max_frame_size, value.frame_max) - self._max_channels = self._negotiate( - self._max_channels, value.channel_max) - - LOGGER.debug('Heartbeat interval (server/client): %r/%r', - value.heartbeat, self._heartbeat_interval) - - # Properly negotiate the heartbeat interval - if self._heartbeat_interval is None: - self._heartbeat_interval = value.heartbeat - elif self._heartbeat_interval == 0 or value.heartbeat == 0: - self._heartbeat_interval = 0 - - self.write_frame(self._build_tune_ok_frame()) - self.write_frame(self._build_open_frame()) - - @staticmethod - def _validate_connection_start(value: commands.Connection.Start) -> bool: - """Validate the received Connection.Start frame - - :param value: Frame to validate - :rtype: bool - """ - if (value.version_major, value.version_minor) != \ - (commands.VERSION[0], commands.VERSION[1]): - LOGGER.warning('AMQP version error (received %i.%i, expected %r)', - value.version_major, - value.version_minor, commands.VERSION) - return False - return True - def _write_protocol_header(self) -> None: - """Send the protocol header to the connected server.""" - self.write_frame(header.ProtocolHeader()) +class Channel0: + def __init__(self): + self.pending_frames = queue.Queue() diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index eaa290e..c44ba40 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -1,54 +1,43 @@ -""" -The Connection class negotiates and manages the connection state. - -""" import logging import queue -try: - import ssl -except ImportError: - ssl = None +import sys import threading -import time import types import typing -from urllib import parse - -from pamqp import base as pamqp_base, commands, constants, header -from rabbitpy import (base, channel as chan, channel0, events, exceptions, - heartbeat, io, message, utils) +from rabbitpy import __version__, channel0, events, io, state, url_parser LOGGER = logging.getLogger(__name__) -AMQP = 'amqp' -AMQPS = 'amqps' -FrameExpectations = typing.Union[str, pamqp_base.Frame, - typing.List[typing.Union[str, - pamqp_base.Frame]]] +class ClientProperties: + information = 'See https://rabbitpy.readthedocs.io' + platform = 'Python {0}.{1}.{2}'.format(*sys.version_info) # noqa: UP030 + product = 'rabbitpy' + version = __version__ + + def __init__(self, additional_properties: dict | None = None): + self._properties: dict[str, str | dict[str, bool]] = { + 'information': self.information, + 'platform': self.platform, + 'product': self.product, + 'version': self.version, + } + if additional_properties: + self._properties.update(additional_properties) + self._properties['capabilities'] = { + 'authentication_failure_close': True, + 'basic.nack': True, + 'connection.blocked': True, + 'consumer_cancel_notify': True, + 'publisher_confirms': True, + } -if ssl: - SSL_CERT_MAP = { - 'ignore': ssl.CERT_NONE, - 'optional': ssl.CERT_OPTIONAL, - 'required': ssl.CERT_REQUIRED - } - SSL_VERSION_MAP = dict() - if hasattr(ssl, 'PROTOCOL_SSLv2'): - SSL_VERSION_MAP['SSLv2'] = getattr(ssl, 'PROTOCOL_SSLv2') - if hasattr(ssl, 'PROTOCOL_SSLv3'): - SSL_VERSION_MAP['SSLv3'] = getattr(ssl, 'PROTOCOL_SSLv3') - if hasattr(ssl, 'PROTOCOL_SSLv23'): - SSL_VERSION_MAP['SSLv23'] = getattr(ssl, 'PROTOCOL_SSLv23') - if hasattr(ssl, 'PROTOCOL_TLSv1'): - SSL_VERSION_MAP['TLSv1'] = getattr(ssl, 'PROTOCOL_TLSv1') -else: - SSL_CERT_MAP, SSL_VERSION_MAP = dict(), dict() + def as_dict(self) -> dict: + return self._properties -# pylint: disable=too-many-instance-attributes -class Connection(base.StatefulObject): +class Connection(state.StatefulBase): """The Connection object is responsible for negotiating a connection and managing its state. When creating a new instance of the Connection object, if no URL is passed in, it uses the default connection parameters of @@ -62,87 +51,72 @@ class Connection(base.StatefulObject): :code:`[scheme]://[username]:[password]@[host]:[port]/[virtual_host]` - The following example connects to the test virtual host on a RabbitMQ + The following example connects to the `test` virtual host on a RabbitMQ server running at 192.168.1.200 port 5672 as the user "www" and the password rabbitmq: - :code:`amqp://admin192.168.1.200:5672/test` + :code:`amqp://www:rabbitmq@192.168.1.200:5672/test` .. note:: You should be aware that most connection exceptions may be raised during the use of all functionality in the library. - :param str url: The AMQP connection URL - :raises: rabbitpy.exceptions.AMQPException - :raises: rabbitpy.exceptions.ConnectionException - :raises: rabbitpy.exceptions.ConnectionResetException - :raises: rabbitpy.exceptions.RemoteClosedException + :param url: The AMQP connection URL + :param connection_name: An optional name for the connection + :param client_properties: Optional client properties to send + :raises: exceptions.AMQPException + :raises: exceptions.ConnectionException + :raises: exceptions.ConnectionResetException + :raises: exceptions.RemoteClosedException """ - CANCEL_METHOD = ['Basic.Cancel'] - DEFAULT_CHANNEL_MAX = 65535 - DEFAULT_TIMEOUT = 3 - DEFAULT_HEARTBEAT_INTERVAL = 60 - DEFAULT_LOCALE = 'en_US' - DEFAULT_URL = 'amqp://guest:guest@localhost:5672/%2F' - DEFAULT_VHOST = '%2F' - GUEST = 'guest' - PORTS = {'amqp': 5672, 'amqps': 5671, 'api': 15672} - QUEUE_WAIT = 0.01 + CANCEL_METHOD: typing.ClassVar[list[str]] = ['Basic.Cancel'] - def __init__(self, url: typing.Optional[str] = None): + def __init__( + self, + url: str | None = None, + connection_name: str | None = None, + client_properties: dict | None = None, + ): """Create a new instance of the Connection object""" - super(Connection, self).__init__() - - # Create a name for the connection - self._name = '0x%x' % id(self) - - # Extract parts of connection URL for use later - self._args = self._process_url(url or self.DEFAULT_URL) - - # General events and queues shared across threads + super().__init__() + self._args = url_parser.parse(url) + self._channel_lock = threading.Lock() + self._channel0 = channel0.Channel0() + self._client_properties = ClientProperties(client_properties) self._events = events.Events() - - # A queue for the child threads to put exceptions in self._exceptions = queue.Queue() - - # One queue for writing frames, regardless of the channel sending them + self._io: io.IO | None = None + self._max_frame_size: int | None = None + self._name = connection_name or '0x%x' % id(self) # noqa: UP031 self._write_queue = queue.Queue() - # Lock used when managing the channel stack - self._channel_lock = threading.Lock() - - # Attributes for core object threads - self._channel0: typing.Optional[channel0.Channel0] = None - self._channels = dict() - self._heartbeat: typing.Optional[heartbeat.Heartbeat] = None - self._io: typing.Optional[io.IO] = None - - # Used by Message for breaking up body frames - self._max_frame_size: typing.Optional[int] = None - - # Connect to RabbitMQ - self._connect() - def __enter__(self) -> 'Connection': """For use as a context manager, return a handle to this object instance. """ + self.connect() return self - def __exit__(self, exc_type: typing.Optional[typing.Type[BaseException]], - exc_val: typing.Optional[BaseException], - unused_exc_tb: typing.Optional[types.TracebackType]): - """When leaving the context, examine why the context is leaving, if - it's an exception or what. - - """ + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ): + """Close the connection when the context exits""" if exc_type and exc_val: self._set_state(self.CLOSED) raise exc_val + try: + exc = self._exceptions.get() + except queue.Empty: + pass + else: + raise exc self.close() @property @@ -159,469 +133,43 @@ def blocked(self) -> bool: that was added in RabbitMQ 3.2. """ - return self._events.is_set(events.CONNECTION_BLOCKED) - - def channel(self, blocking_read: bool = False) -> chan.Channel: - """Create a new channel - - If blocking_read is True, the cross-thread `Queue.get` use will use - blocking operations that lower resource utilization and increase - throughput. However, due to how Python's blocking `Queue.get` is - implemented, KeyboardInterrupt is not raised when CTRL-C is - pressed. - - :param blocking_read: Enable for higher throughput - :raises: rabbitpy.exceptions.AMQPException - :raises: rabbitpy.exceptions.RemoteClosedChannelException - - """ - with self._channel_lock: - channel_id = self._get_next_channel_id() - channel_frames = queue.Queue() - self._channels[channel_id] = \ - chan.Channel(channel_id, - self.capabilities, - self._events, - self._exceptions, - channel_frames, - self._write_queue, - self._max_frame_size, - self._io.write_trigger, - self, - blocking_read) - self._add_channel_to_io(self._channels[channel_id], channel_frames) - self._channels[channel_id].open() - return self._channels[channel_id] + return self._events.is_set(events.CONNECTION_BLOCKED) or False def close(self) -> None: - """Close the connection, including all open channels. - - :raises: rabbitpy.exceptions.ConnectionClosed - - """ - if not self.open and not self.opening: - raise exceptions.ConnectionClosed() - if not self._events.is_set(events.SOCKET_CLOSED): - self._set_state(self.CLOSING) - self._shutdown_connection() - LOGGER.debug('Setting to closed') - self._set_state(self.CLOSED) - - @property - def capabilities(self) -> dict: - """Return the RabbitMQ Server capabilities from the connection - negotiation process. - - """ - return self._channel0.properties.get('capabilities', dict()) - - @property - def server_properties(self) -> dict: - """Return the RabbitMQ Server properties from the connection - negotiation process. - - """ - return self._channel0.properties - - def _add_channel_to_io(self, - channel: typing.Union[chan.Channel, - channel0.Channel0], - channel_queue: typing.Optional[queue.Queue]) \ - -> None: - """Add a channel and queue to the IO object. - - :param channel: The channel object to add - :param channel_queue: Channel inbound msg queue - - """ - LOGGER.debug('Adding channel %s to io', channel.id) - self._io.add_channel(channel, channel_queue) - - @property - def _api_credentials(self) -> typing.Tuple[str, str]: - """Return the auth credentials as a tuple""" - return self._args['username'], self._args['password'] - - @property - def _channel0_closed(self) -> bool: - """Returns a boolean indicating if the base connection channel (0) - is closed. - - """ - return self._channel0.open and not \ - self._events.is_set(events.CHANNEL0_CLOSED) - - def _close_all_channels(self) -> None: - """Close all open channels""" - for chan_id in [ - chan_id for chan_id in self._channels - if not self._channels[chan_id].closed - ]: - self._channels[chan_id].close() - self._channel0.close() - - def _close_channels(self) -> None: - """Close all the channels that are currently open.""" - for channel_id in self._channels: - if (self._channels[channel_id].open - and not self._channels[channel_id].closing): - self._channels[channel_id].close() - - def _connect(self) -> None: + """Close the connection to RabbitMQ.""" + self._set_state(self.CLOSING) + self._events.clear(events.CONNECTION_EVENT) + self._events.clear(events.SOCKET_CLOSE) + self._events.clear(events.SOCKET_CLOSED) + self._events.clear(events.SOCKET_OPENED) + self._write_queue.put_nowait(None) + + def connect(self) -> None: """Connect to the RabbitMQ Server""" self._set_state(self.OPENING) - # Create and start the IO object that reads, writes & dispatches frames - self._io = self._create_io_thread() - self._io.daemon = True + self._io = io.IO( + self._args['host'], + self._args['port'], + self._args['ssl'], + self._args['ssl_options'], + self._events, + self._exceptions, + self._args['timeout'], + ) + self._io.add_channel(0, self._channel0.pending_frames) self._io.start() - # Wait for IO to connect to the socket or raise an exception - while self.opening and not self._events.is_set(events.SOCKET_OPENED): + while self.is_opening and not self._events.is_set( + events.SOCKET_OPENED + ): if not self._exceptions.empty(): exception = self._exceptions.get() raise exception - # :meth:`threading.html#threading.Event.wait` always returns None - # in 2.6, so it is impossible to simply check wait() result self._events.wait(events.SOCKET_OPENED, self._args['timeout']) if not self._events.is_set(events.SOCKET_OPENED): - raise RuntimeError("Timeout waiting for opening the socket") + raise RuntimeError('Timeout waiting for opening the socket') # If the socket could not be opened, return instead of waiting - if self.closed: + if self.is_closed: return self.close() - - # Create the Channel0 queue and add it to the IO thread - self._channel0 = self._create_channel0() - self._add_channel_to_io(self._channel0, None) - self._channel0.start() - - # Wait for Channel0 to raise an exception or negotiate the connection - timeout_start = time.time() - while time.time() < timeout_start + self.DEFAULT_TIMEOUT: - - if self._channel0.open: - break - - if not self._exceptions.empty(): - exception = self._exceptions.get() - self._io.stop() - raise exception - - time.sleep(0.01) - - # Raise exception if channel not opened before timeout - if not self._channel0.open: - raise exceptions.ConnectionException - - # Set the maximum frame size for channel use - self._max_frame_size = self._channel0.maximum_frame_size - - # Create the heartbeat checker - self._heartbeat = heartbeat.Heartbeat(self._io, self._channel0, - self._args['heartbeat']) - self._heartbeat.start() - self._set_state(self.OPEN) - - def _create_channel0(self) -> channel0.Channel0: - """Each connection should have a distinct channel0""" - return channel0.Channel0(connection_args=self._args, - events_obj=self._events, - exception_queue=self._exceptions, - write_queue=self._write_queue, - write_trigger=self._io.write_trigger, - connection=self) - - def _create_io_thread(self) -> io.IO: - """Create the IO thread and the objects it uses for communication.""" - return io.IO(name='%s-io' % self._name, - kwargs={ - 'events': self._events, - 'exceptions': self._exceptions, - 'connection_args': self._args, - 'write_queue': self._write_queue - }) - - def _create_message(self, channel_id: int, - method_frame: typing.Union[commands.Basic.Deliver, - commands.Basic.GetOk, - commands.Basic.Deliver], - header_frame: header.ContentHeader, - body: typing.Union[bytes, str]) -> message.Message: - """Create a message instance with the channel it was received on and - the dictionary of message parts. - - :param int channel_id: The channel id the message was sent on - :param method_frame: The method frame value - :param header_frame: The header frame value - :param body: The message body - - """ - msg = message.Message(self._channels[channel_id], body, - header_frame.properties.to_dict()) - msg.method = method_frame - msg.name = method_frame.name - return msg - - def _get_next_channel_id(self) -> int: - """Return the next channel id""" - if not self._channels: - return 1 - if self._max_channel_id == self._channel0.maximum_channels: - raise exceptions.TooManyChannelsError - return self._max_channel_id + 1 - - @property - def _max_channel_id(self) -> int: - """Return the maximum channel ID that is currently being used.""" - return max(list(self._channels.keys())) - - @staticmethod - def _normalize_expectations(channel_id: int, - expectations: FrameExpectations) \ - -> typing.List[str]: - """Turn a class or list of classes into a list of class names. - - :param channel_id: The channel to normalize for - :param expectations: List of classes or class name or class obj - - """ - if isinstance(expectations, list): - output = list() - for value in expectations: - if isinstance(value, str): - output.append(f'{channel_id}:{value}') - else: - output.append(f'{channel_id}:{value.name}') - return output - elif isinstance(expectations, str): - return [f'{channel_id}:{expectations}'] - return [f'{channel_id}:{expectations.name}'] - - def _process_url(self, url: str) -> dict: - """Parse the AMQP URL passed in and return the configuration - information in a dictionary of values. - - The URL format is as follows: - - amqp[s]://username:password@host:port/virtual_host[?query string] - - Values in the URL such as the virtual_host should be URL encoded or - quoted just as a URL would be in a web browser. The default virtual - host / in RabbitMQ should be passed as %2F. - - Default values: - - - If port is omitted, port 5762 is used for AMQP and port 5671 is - used for AMQPS - - If username or password is omitted, the default value is guest - - If the virtual host is omitted, the default value of %2F is used - - Query string options: - - - heartbeat - - channel_max - - frame_max - - locale - - cacertfile - Path to CA certificate file - - certfile - Path to client certificate file - - keyfile - Path to client certificate key - - verify - Server certificate validation requirements (1) - - ssl_version - SSL version to use (2) - - (1) Should be one of three values: - - - ignore - Ignore the cert if provided (default) - - optional - Cert is validated if provided - - required - Cert is required and validated - - (2) Should be one of four values: - - - SSLv2 - - SSLv3 - - SSLv23 - - TLSv1 - - :param str url: The AMQP url passed in - :raises: ValueError - - """ - parsed = utils.urlparse(url) - - self._validate_uri_scheme(parsed.scheme) - - # Toggle the SSL flag based upon the URL scheme and if SSL is enabled - use_ssl = True if parsed.scheme == 'amqps' and ssl else False - - # Ensure that SSL is available if SSL is requested - if parsed.scheme == 'amqps' and not ssl: - LOGGER.warning('SSL requested but not available, disabling') - - # Figure out the port as specified by the scheme - scheme_port = self.PORTS[AMQPS] if parsed.scheme == AMQPS \ - else self.PORTS[AMQP] - - # Set the vhost to be after the base slash if it was specified - vhost = self.DEFAULT_VHOST - if parsed.path: - vhost = parsed.path[1:] or self.DEFAULT_VHOST - - # Parse the query string - query_args = parse.parse_qs(parsed.query) - - # Return the configuration dictionary to use when connecting - return { - 'host': parsed.hostname, - 'port': parsed.port or scheme_port, - 'virtual_host': parse.unquote(vhost), - 'username': parse.unquote(parsed.username or self.GUEST), - 'password': parse.unquote(parsed.password or self.GUEST), - 'timeout': self._query_args_int( - 'timeout', query_args, self.DEFAULT_TIMEOUT), - 'heartbeat': self._query_args_int( - 'heartbeat', query_args, self.DEFAULT_HEARTBEAT_INTERVAL), - 'frame_max': self._query_args_int( - 'frame_max', query_args, constants.FRAME_MAX_SIZE), - 'channel_max': self._query_args_int( - 'channel_max', query_args, self.DEFAULT_CHANNEL_MAX), - 'locale': self._query_args_value('locale', query_args), - 'ssl': use_ssl, - 'cacertfile': self._query_args_mk_value( - ['cacertfile', 'ssl_cacert'], query_args), - 'certfile': self._query_args_mk_value( - ['certfile', 'ssl_cert'], query_args), - 'keyfile': self._query_args_mk_value( - ['keyfile', 'ssl_key'], query_args), - 'verify': self._query_args_ssl_validation(query_args), - 'ssl_version': self._query_args_ssl_version(query_args) - } - - @staticmethod - def _query_args_int(key: str, values: dict, default: int) -> int: - """Return the query arg value as an integer for the specified key or - return the specified default value. - - :param key: The key to return the value for - :param values: The query value dict returned by urlparse - :param default: The default return value - - """ - return int(values.get(key, [default])[0]) - - @staticmethod - def _query_args_float(key: str, values: dict, default: float) -> float: - """Return the query arg value as a float for the specified key or - return the specified default value. - - :param key: The key to return the value for - :param values: The query value dict returned by urlparse - :param default: The default return value - - """ - return float(values.get(key, [default])[0]) - - def _query_args_ssl_validation(self, values: dict) \ - -> typing.Union[int, None]: - """Return the value mapped from the string value in the query string - for the AMQP URL specifying which level of server certificate - validation is required, if any. - - :param values: The dict of query values from the AMQP URI - - """ - validation = self._query_args_mk_value(['verify', 'ssl_validation'], - values) - if not validation: - return - elif validation not in SSL_CERT_MAP: - raise ValueError('Unsupported server cert validation option: %s', - validation) - return SSL_CERT_MAP[validation] - - def _query_args_ssl_version(self, values: dict) \ - -> typing.Union[int, None]: - """Return the value mapped from the string value in the query string - for the AMQP URL for SSL version. - - :param values: The dict of query values from the AMQP URI - - """ - version = self._query_args_value('ssl_version', values) - if not version: - return - elif version not in SSL_VERSION_MAP: - raise ValueError(f'Unsupported SSL version: {version}') - return SSL_VERSION_MAP[version] - - @staticmethod - def _query_args_value(key: str, values: dict, - default: typing.Union[int, float, - str, None] = None) \ - -> typing.Union[int, float, str, None]: - """Return the value from the query arguments for the specified key - or the default value. - - :param key: The key to get the value for - :param values: The query value dict returned by urlparse - - """ - return values.get(key, [default])[0] - - def _query_args_mk_value(self, keys: typing.List[str], values: dict) \ - -> typing.Union[int, float, str, None]: - """Try and find the query string value where the value can be specified - with different keys. - - :param keys: The keys to check - :param values: The query value dict returned by urlparse - - """ - for key in keys: - value = self._query_args_value(key, values) - if value is not None: - return value - return None - - def _shutdown_connection(self) -> None: - """Tell Channel0 and IO to stop if they are not stopped.""" - # Make sure the heartbeat is not running - if self._heartbeat is not None: - self._heartbeat.stop() - - if not self._events.is_set(events.SOCKET_CLOSED): - self._close_all_channels() - - # Let the IOLoop know to close - self._events.set(events.SOCKET_CLOSE) - - # Break out of select waiting - self._trigger_write() - - if (self._events.is_set(events.SOCKET_OPENED) - and not self._events.is_set(events.SOCKET_CLOSED)): - LOGGER.debug('Waiting on socket to close') - self._events.wait(events.SOCKET_CLOSED, 0.1) - - self._io.stop() - else: - return self._io.stop() - - while self._io.is_alive(): - time.sleep(0.1) - - def _trigger_write(self) -> None: - """Notifies the IO loop we need to write a frame by writing a byte - to a local socket. - - """ - utils.trigger_write(self._io.write_trigger) - - def _validate_uri_scheme(self, scheme: str) -> None: - """Ensure that the specified URI scheme is supported by rabbitpy - - :param scheme: The value to validate - :raises: ValueError - - """ - if scheme not in list(self.PORTS.keys()): - raise ValueError('Unsupported URI scheme: %s' % scheme) diff --git a/rabbitpy/events.py b/rabbitpy/events.py index 748d7bf..3df901e 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -5,7 +5,6 @@ import logging import threading -import typing LOGGER = logging.getLogger(__name__) @@ -55,19 +54,21 @@ def _create_event_objects() -> dict[int, threading.Event]: """ events = {} - for event in [CHANNEL0_CLOSE, - CHANNEL0_CLOSED, - CHANNEL0_OPENED, - CONNECTION_BLOCKED, - CONNECTION_EVENT, - EXCEPTION_RAISED, - SOCKET_CLOSE, - SOCKET_CLOSED, - SOCKET_OPENED]: + for event in [ + CHANNEL0_CLOSE, + CHANNEL0_CLOSED, + CHANNEL0_OPENED, + CONNECTION_BLOCKED, + CONNECTION_EVENT, + EXCEPTION_RAISED, + SOCKET_CLOSE, + SOCKET_CLOSED, + SOCKET_OPENED, + ]: events[event] = threading.Event() return events - def clear(self, event_id: int) -> typing.Union[bool, None]: + def clear(self, event_id: int) -> bool | None: """Clear a set event, returning bool indicating success and None for an invalid event. @@ -85,7 +86,7 @@ def clear(self, event_id: int) -> typing.Union[bool, None]: self._events[event_id].clear() return True - def is_set(self, event_id: int) -> typing.Union[bool, None]: + def is_set(self, event_id: int) -> bool | None: """Check if an event is triggered. Returns bool indicating state of the event being set. If the event is invalid, a None is returned instead. @@ -97,7 +98,7 @@ def is_set(self, event_id: int) -> typing.Union[bool, None]: return None return self._events[event_id].is_set() - def set(self, event_id: int) -> typing.Union[bool, None]: + def set(self, event_id: int) -> bool | None: """Trigger an event to fire. Returns bool indicating success in firing the event. If the event is not valid, return None. @@ -115,9 +116,7 @@ def set(self, event_id: int) -> typing.Union[bool, None]: self._events[event_id].set() return True - def wait(self, - event_id: int, - timeout: float = 1) -> typing.Union[bool, None]: + def wait(self, event_id: int, timeout: float = 1) -> bool | None: """Wait for an event to be set for up to `timeout` seconds. If `timeout` is None, block until the event is set. If the event is invalid, None will be returned, otherwise False is used to indicate @@ -130,6 +129,9 @@ def wait(self, if event_id not in self._events: LOGGER.debug('Event does not exist: %s', description(event_id)) return None - LOGGER.debug('Waiting for %i seconds on event: %s', - timeout, description(event_id)) + LOGGER.debug( + 'Waiting for %i seconds on event: %s', + timeout, + description(event_id), + ) return self._events[event_id].wait(timeout) diff --git a/rabbitpy/exceptions.py b/rabbitpy/exceptions.py index 9f8d3c9..9e7bece 100644 --- a/rabbitpy/exceptions.py +++ b/rabbitpy/exceptions.py @@ -85,8 +85,10 @@ class RemoteClosedChannelException(RabbitpyException): """ def __str__(self): - return f'Channel {self.args[0]} was closed by the remote server ' \ - f'({self.args[1]}): {self.args[2]}' + return ( + f'Channel {self.args[0]} was closed by the remote server ' + f'({self.args[1]}): {self.args[2]}' + ) class RemoteClosedException(RabbitpyException): @@ -96,8 +98,10 @@ class RemoteClosedException(RabbitpyException): """ def __str__(self): - return f'Connection was closed by the remote server ' \ - f'({self.args[0]}): {self.args[1]}' + return ( + f'Connection was closed by the remote server ' + f'({self.args[0]}): {self.args[1]}' + ) class MessageReturnedException(RabbitpyException): @@ -107,8 +111,10 @@ class MessageReturnedException(RabbitpyException): """ def __str__(self): - return f'Message was returned by RabbitMQ: ({self.args[0]}) ' \ - f'for exchange {self.args[1]}' + return ( + f'Message was returned by RabbitMQ: ({self.args[0]}) ' + f'for exchange {self.args[1]}' + ) class NoActiveTransactionError(RabbitpyException): @@ -168,8 +174,10 @@ class UnexpectedResponseError(RabbitpyException): """ def __str__(self): - return f'Received an expected response, expected {self.args[0]}, ' \ - f'received {self.args[1]}' + return ( + f'Received an expected response, expected {self.args[0]}, ' + f'received {self.args[1]}' + ) # AMQP Exceptions diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py deleted file mode 100644 index 7299474..0000000 --- a/rabbitpy/exchange.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -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, 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 = dict() - auto_delete = False - type = 'direct' - - def __init__(self, - channel: chan.Channel, - name: str, - durable: bool = False, - auto_delete: bool = False, - arguments: typing.Optional[dict] = None): - """Create a new instance of the exchange object.""" - super(_Exchange, self).__init__(channel, name) - self.durable = durable - self.auto_delete = auto_delete - self.arguments = arguments or dict() - - def bind(self, - source: ExchangeTypes, - routing_key: typing.Optional[str] = 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: typing.Optional[str] = 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): - """Create a new instance of the exchange object.""" - self.type = exchange_type - super(Exchange, self).__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 deleted file mode 100644 index c0f27c9..0000000 --- a/rabbitpy/heartbeat.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -The heartbeat class implements the logic for sending heartbeats every -configured interval. - -""" -import logging -import threading -import typing - -from rabbitpy import channel0 as rabbitpy_channel0, 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: typing.Optional[threading.Timer] = 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/io.py b/rabbitpy/io.py index 6aa903f..2112ba8 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -19,32 +19,43 @@ LOGGER = logging.getLogger(__name__) -AddrInfo = typing.Union[ - list[typing.Any], # Fallback for unknown/variable types from getaddrinfo - list[ +PamqpFrame = ( + pamqp.base.Frame + | pamqp.body.ContentBody + | pamqp.header.ContentHeader + | pamqp.header.ProtocolHeader + | pamqp.heartbeat.Heartbeat + | None +) + +AddrInfo = ( + list[typing.Any] + | list[ tuple[ socket.AddressFamily, socket.SocketKind, int, str, tuple[str, int], - typing.Optional[tuple[str, int, int, int]] # IPv6 sockaddr + tuple[str, int, int, int] | None, # IPv6 sockaddr ] ] -] +) class IO(threading.Thread): """Handles asynchronous I/O for RabbitMQ connections.""" - def __init__(self, - host: str, - port: int, - use_ssl: bool, - ssl_options: dict, - events: rabbitpy.events.Events, - exceptions: queue.Queue, - timeout: float = 0.01): + def __init__( + self, + host: str, + port: int, + use_ssl: bool, + ssl_options: dict, + events: rabbitpy.events.Events, + exceptions: queue.Queue, + timeout: float = 0.01, + ): """Initialize the IO thread.""" super().__init__(daemon=True, name='IO') self._events = events @@ -62,10 +73,11 @@ def __init__(self, self._bytes_read = 0 self._bytes_written = 0 self._channels: collections.defaultdict[int, queue.Queue] = ( - collections.defaultdict(queue.Queue)) - self._ioloop: typing.Optional[asyncio.AbstractEventLoop] = None - self._remote_name: typing.Optional[str] = None - self._socket: typing.Optional[socket.socket] = None + collections.defaultdict(queue.Queue) + ) + self._ioloop: asyncio.AbstractEventLoop | None = None + self._remote_name: str | None = None + self._socket: socket.socket | None = None self._write_buffer = collections.deque() def add_channel(self, channel: int, read_queue: queue.Queue) -> None: @@ -92,14 +104,14 @@ def run(self) -> None: finally: self.close() - def write_frame(self, - channel: int, - frame_value: pamqp.base.Frame) -> None: + def write_frame(self, channel: int, frame_value: pamqp.base.Frame) -> None: """Write an AMQP frame to the socket.""" if not self.is_connected: self._add_exception( rabbitpy.exceptions.ConnectionException( - self._host, self._port, 'Not connected')) + self._host, self._port, 'Not connected' + ) + ) return with self._lock: payload = pamqp.frame.marshal(frame_value, channel) @@ -124,7 +136,7 @@ def is_connected(self) -> bool: return not self._closed.is_set() @property - def remote_name(self) -> typing.Optional[str]: + def remote_name(self) -> str | None: """Return the remote socket name.""" with self._lock: return self._remote_name @@ -171,7 +183,9 @@ async def _connect(self) -> None: if not sock: return self._add_exception( rabbitpy.exceptions.ConnectionException( - self._host, self._port, 'Could not connect')) + self._host, self._port, 'Could not connect' + ) + ) self._closed.clear() self._socket = sock @@ -179,11 +193,9 @@ async def _connect(self) -> None: self._socket.settimeout(self._timeout) self._events.set(rabbitpy.events.SOCKET_OPENED) - def _create_socket(self, - address_family: int, - sock_type: int, - protocol: int) \ - -> typing.Union[socket.socket, ssl.SSLSocket]: + def _create_socket( + self, address_family: int, sock_type: int, protocol: int + ) -> socket.socket | ssl.SSLSocket: """Create a new socket, optionally wrapped with SSL.""" sock = socket.socket(address_family, sock_type, protocol) context = self._get_ssl_context() @@ -196,47 +208,44 @@ def _get_addr_info(self) -> AddrInfo: family = socket.AF_UNSPEC if socket.has_ipv6 else socket.AF_INET try: res = socket.getaddrinfo( - self._host, self._port, family, socket.SOCK_STREAM, 0) + self._host, self._port, family, socket.SOCK_STREAM, 0 + ) except OSError as error: LOGGER.debug('Could not resolve %s: %s', self._host, error) return [] return res - def _get_ssl_context(self) -> typing.Optional[ssl.SSLContext]: + def _get_ssl_context(self) -> ssl.SSLContext | None: """Create and configure an SSL context if SSL is enabled.""" if self._use_ssl: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if self._ssl_options.get('check_hostname') is not None: - ssl_context.check_hostname = \ - self._ssl_options['check_hostname'] - if self._ssl_options.get('cafile') \ - or self._ssl_options.get('capath'): + ssl_context.check_hostname = self._ssl_options[ + 'check_hostname' + ] + if self._ssl_options.get('cafile') or self._ssl_options.get( + 'capath' + ): ssl_context.load_verify_locations( cafile=self._ssl_options.get('cafile'), - capath=self._ssl_options.get('capath')) + capath=self._ssl_options.get('capath'), + ) else: ssl_context.load_default_certs(ssl.Purpose.SERVER_AUTH) if self._ssl_options.get('certfile'): ssl_context.load_cert_chain( self._ssl_options.get('certfile', ''), - self._ssl_options.get('keyfile')) + self._ssl_options.get('keyfile'), + ) if self._ssl_options.get('verify') is not None: ssl_context.verify_mode = self._ssl_options['verify'] return ssl_context return None @staticmethod - def _on_data_received(value: bytes) \ - -> tuple[bytes, - typing.Optional[int], - typing.Optional[ - typing.Union[ - pamqp.base.Frame, - pamqp.body.ContentBody, - pamqp.header.ContentHeader, - pamqp.header.ProtocolHeader, - pamqp.heartbeat.Heartbeat]], - int]: + def _on_data_received( + value: bytes, + ) -> tuple[bytes, int | None, PamqpFrame, int]: """Unmarshal a pamqp frame from received socket data. @@ -257,16 +266,20 @@ def _on_error(self, exception: OSError) -> None: """Handle socket errors, add exceptions, and signal events.""" if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): return - self._add_exception(rabbitpy.exceptions.ConnectionException( - self._host, self._port, str(exception))) + self._add_exception( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, str(exception) + ) + ) def _on_read_ready(self) -> None: """Read data from the socket and process any complete frames.""" with self._lock: self._buffer += self._socket.recv(32768) while self._buffer: - remaining, channel_id, frame_value, bytes_read =\ + remaining, channel_id, frame_value, bytes_read = ( self._on_data_received(self._buffer) + ) if channel_id is None: raise RuntimeError('Invalid channel ID received') if remaining == self._buffer: @@ -284,9 +297,10 @@ def _on_write_ready(self) -> None: return try: self._socket.sendall(payload) - except socket.timeout: - LOGGER.warning('Timed out writing %i bytes to socket', - len(payload)) + except TimeoutError: + LOGGER.warning( + 'Timed out writing %i bytes to socket', len(payload) + ) with self._lock: self._write_buffer.appendleft(payload) except OSError as error: @@ -313,7 +327,8 @@ async def _run(self): self._ioloop.add_writer(self._socket, self._on_write_ready) local_sock = self._socket.getsockname() peer_sock = self._socket.getpeername() - self._remote_name = \ + self._remote_name = ( f'{local_sock[0]}:{local_sock[1]} -> {peer_sock[0]}:{peer_sock[1]}' + ) await self._closed.wait() diff --git a/rabbitpy/message.py b/rabbitpy/message.py deleted file mode 100644 index 503b0e6..0000000 --- a/rabbitpy/message.py +++ /dev/null @@ -1,376 +0,0 @@ -""" -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 pprint -import uuid - -from pamqp import body, commands, common, header - -from rabbitpy import base, channel as chan, exceptions, exchange as exc, utils - -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: typing.Union[str, bytes, memoryview, dict, list], - properties: typing.Optional[dict] = None, - opinionated: bool = False): - """Create a new instance of the Message object.""" - super(Message, self).__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('Invalid property: {}'.format( - self._invalid_properties[0])) - - @property - def delivery_tag(self) -> typing.Union[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) -> typing.Union[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) -> typing.Union[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) -> typing.Union[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 - - """ - print('Exchange: %s\n' % self.method.exchange) - print('Routing Key: %s\n' % self.method.routing_key) - if properties: - print('Properties:\n') - pprint.pprint(self.properties) - print('\nBody:\n') - pprint.pprint(self.body) - - def publish(self, - exchange: exc.ExchangeTypes, - routing_key: str = '', - mandatory: bool = False, - immediate: bool = False) -> typing.Union[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 = int( - 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.utcnow() - - @staticmethod - def _as_datetime(value: typing.Union[datetime.datetime, - time.struct_time, int, float, - str, bytes]) \ - -> typing.Union[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]) - - if isinstance(value, (bytes, str)): - value = int(value) - - if isinstance(value, float) or isinstance(value, int): - return datetime.datetime.fromtimestamp(value) - - raise TypeError( - f'Could not cast a {value} value to a datetime.datetime') - - def _auto_serialize(self, value: typing.Union[str, bytes, memoryview, - dict, list]) \ - -> typing.Union[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 - - 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 - if commands.Basic.Properties.__annotations__[key] == 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 commands.Basic.Properties.__annotations__[key] == 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 commands.Basic.Properties.__annotations__[key] == \ - common.FieldTable and not isinstance(value, dict): - LOGGER.warning('Resetting invalid value for %s to None', key) - self.properties[key] = {} - if commands.Basic.Properties.__annotations__[key] == \ - datetime.datetime: - self.properties[key] = self._as_datetime(value) - - @property - def _invalid_properties(self) -> typing.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 new file mode 100644 index 0000000..e69de29 diff --git a/rabbitpy/simple.py b/rabbitpy/simple.py deleted file mode 100644 index 80ad745..0000000 --- a/rabbitpy/simple.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -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: typing.Optional[typing.Type[BaseException]], - exc_val: typing.Optional[BaseException], - unused_exc_tb: typing.Optional[types.TracebackType]): - 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: typing.Optional[str] = None, - queue_name: typing.Optional[str] = None, - no_ack: typing.Optional[bool] = False, - prefetch: typing.Optional[int] = None, - priority: typing.Optional[int] = 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) - for msg in queue.consume(no_ack, prefetch, priority): - yield msg - - -def get(uri: typing.Optional[str] = None, - queue_name: typing.Optional[str] = None) \ - -> typing.Union[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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = None, - routing_key: typing.Optional[str] = None, - body: typing.Optional[bytes, str] = None, - properties: typing.Optional[dict] = None, - confirm: bool = False) -> typing.Union[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 dict()) - 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: typing.Optional[str] = None, - queue_name: str = '', - durable: bool = True, - auto_delete: bool = False, - max_length: typing.Optional[int] = None, - message_ttl: typing.Optional[int] = None, - expires: typing.Optional[int] = None, - dead_letter_exchange: typing.Optional[str] = None, - dead_letter_routing_key: typing.Optional[str] = None, - arguments: typing.Optional[dict] = 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: typing.Optional[str] = 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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = 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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = 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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = 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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = 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: typing.Optional[str] = None, - exchange_name: typing.Optional[str] = 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: typing.Optional[str], - exchange_name: typing.Optional[str], - 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/state.py b/rabbitpy/state.py new file mode 100644 index 0000000..aba9e19 --- /dev/null +++ b/rabbitpy/state.py @@ -0,0 +1,68 @@ +"""Common base class for objects that need to maintain state.""" + +import typing + + +class StatefulBase: + """Base class for classes that need to maintain state such as + connection and channel. + + """ + + CLOSED = 0 + CLOSING = 1 + OPEN = 2 + OPENING = 3 + + STATES: typing.ClassVar[dict[int, str]] = { + 0: 'Closed', + 1: 'Closing', + 2: 'Open', + 3: 'Opening', + } + + def __init__(self, *args, **kwargs): + """Create a new instance of the object defaulting to a closed state.""" + self._state: int = self.CLOSED + super().__init__(*args, **kwargs) + + 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}') + self._state = value + + @property + def is_closed(self) -> bool: + """Returns True if in the CLOSED runtime state""" + return self._state == self.CLOSED + + @property + def is_closing(self) -> bool: + """Returns True if in the CLOSING runtime state""" + return self._state == self.CLOSING + + @property + def is_open(self) -> bool: + """Returns True if in the OPEN runtime state""" + return self._state == self.OPEN + + @property + def is_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] diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py deleted file mode 100644 index 10e23c8..0000000 --- a/rabbitpy/tx.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -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 -import typing - -from pamqp import commands - -from rabbitpy import base, channel as chan, exceptions - -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(Tx, self).__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: typing.Optional[typing.Type[BaseException]], - exc_val: typing.Optional[BaseException], - unused_exc_tb: typing.Optional[types.TracebackType]): - """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() - 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() - self._selected = False - return isinstance(response, commands.Tx.RollbackOk) diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index 207f5a6..8f12549 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -1,7 +1,7 @@ """Parse URLS""" + import logging import ssl -import typing import urllib.parse from pamqp import constants @@ -21,11 +21,11 @@ SSL_CERT_MAP = { 'ignore': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, - 'required': ssl.CERT_REQUIRED + 'required': ssl.CERT_REQUIRED, } -def parse(url: typing.Optional[str] = DEFAULT_URL) -> dict: +def parse(url: str | None = DEFAULT_URL) -> dict: """Parse the AMQP URL passed in and return the configuration information in a dictionary of values. @@ -94,8 +94,10 @@ def parse(url: typing.Optional[str] = DEFAULT_URL) -> dict: # Parse the query string query_args = urllib.parse.parse_qs( - parsed.query.decode('utf-8') if isinstance(parsed.query, bytes) - else parsed.query) + parsed.query.decode('utf-8') + if isinstance(parsed.query, bytes) + else parsed.query + ) # Return the configuration dictionary to use when connecting return { @@ -106,26 +108,33 @@ def parse(url: typing.Optional[str] = DEFAULT_URL) -> dict: 'password': urllib.parse.unquote(parsed.password or GUEST), 'timeout': _query_args_int('timeout', query_args, DEFAULT_TIMEOUT), 'heartbeat': _query_args_int( - 'heartbeat', query_args, DEFAULT_HEARTBEAT_INTERVAL), + 'heartbeat', query_args, DEFAULT_HEARTBEAT_INTERVAL + ), 'frame_max': _query_args_int( - 'frame_max', query_args, constants.FRAME_MAX_SIZE), + 'frame_max', query_args, constants.FRAME_MAX_SIZE + ), 'channel_max': _query_args_int( - 'channel_max', query_args, DEFAULT_CHANNEL_MAX), + 'channel_max', query_args, DEFAULT_CHANNEL_MAX + ), 'locale': _query_args_str('locale', query_args), 'ssl': use_ssl, 'ssl_options': { 'check_hostname': _query_args_str( - 'ssl_check_hostname', query_args, '').lower() - not in ('0', 'false', 'no'), + 'ssl_check_hostname', query_args, '' + ).lower() + not in ('0', 'false', 'no'), 'cafile': _query_args_multi_key_value( - ['cacertfile', 'ssl_cacert', 'cafile'], query_args), + ['cacertfile', 'ssl_cacert', 'cafile'], query_args + ), 'capath': _query_args_str('capath', query_args), 'certfile': _query_args_multi_key_value( - ['certfile', 'ssl_cert'], query_args), + ['certfile', 'ssl_cert'], query_args + ), 'keyfile': _query_args_multi_key_value( - ['keyfile', 'ssl_key'], query_args), - 'verify': _query_args_ssl_validation(query_args) - } + ['keyfile', 'ssl_key'], query_args + ), + 'verify': _query_args_ssl_validation(query_args), + }, } @@ -142,8 +151,8 @@ def _query_args_int(key: str, values: dict, default: int) -> int: def _query_args_str( - key: str, values: dict, - default: typing.Optional[str] = None) -> typing.Optional[str]: + key: str, values: dict, default: str | None = None +) -> str | None: """Return the value from the query arguments for the specified key or the default value. @@ -154,8 +163,9 @@ def _query_args_str( return values.get(key, [default])[0] -def _query_args_multi_key_value(keys: list[str], values: dict) \ - -> typing.Union[int, float, str, None]: +def _query_args_multi_key_value( + keys: list[str], values: dict +) -> int | float | str | None: """Try and find the query string value where the value can be specified with different keys. @@ -170,7 +180,7 @@ def _query_args_multi_key_value(keys: list[str], values: dict) \ return None -def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: +def _query_args_ssl_validation(values: dict) -> int | None: """Return the value mapped from the string value in the query string for the AMQP URL specifying which level of server certificate validation is required, if any. @@ -179,16 +189,18 @@ def _query_args_ssl_validation(values: dict) -> typing.Union[int, None]: """ validation = _query_args_multi_key_value( - ['verify', 'ssl_validation'], values) + ['verify', 'ssl_validation'], values + ) if not validation: return None elif validation not in SSL_CERT_MAP: raise ValueError( - f'Unsupported server cert validation option: {validation}') + f'Unsupported server cert validation option: {validation}' + ) return SSL_CERT_MAP[validation] -def _validate_uri_scheme(scheme: typing.Union[bytes, str]) -> None: +def _validate_uri_scheme(scheme: bytes | str) -> None: """Ensure that the specified URI scheme is supported by rabbitpy :param scheme: The value to validate diff --git a/rabbitpy/utils.py b/rabbitpy/utils.py deleted file mode 100644 index 827d34e..0000000 --- a/rabbitpy/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Utilities -========= - -""" -import collections -import logging -import platform -import socket -import typing -from urllib import parse - -LOGGER = logging.getLogger('rabbitpy') -PYPY = platform.python_implementation() == 'PyPy' - -Parsed = collections.namedtuple( - 'Parsed', - 'scheme,netloc,path,params,query,fragment,username,password,hostname,port') - - -def maybe_utf8_encode(value: typing.Union[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 = 'http%s' % 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 trigger_write(sock: socket.socket) -> None: - try: - sock.send(b'0') - except socket.error: - pass - - -class DebuggingOptimizationMixin: - """Micro-optimization to avoid logging overhead""" - - def __init__(self): - self._debugging: typing.Optional[bool] = 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 deleted file mode 100644 index 89b8d2f..0000000 --- a/tests/base_tests.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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_unicode(self): - obj = base.AMQPClass(self.channel, unicode('Foo')) - self.assertIsInstance(obj.name, unicode) - - 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 deleted file mode 100644 index e8e7004..0000000 --- a/tests/channel_test.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -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/events_tests.py b/tests/events_tests.py deleted file mode 100644 index 75580cf..0000000 --- a/tests/events_tests.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Test the rabbitpy events class - -""" -import mock -import threading -try: - import unittest2 as unittest -except ImportError: - import unittest - -from rabbitpy import events - - -class BaseEventsTest(unittest.TestCase): - - def setUp(self): - self._events = events.Events() - - def tearDown(self): - del self._events - - -class EventClearTests(BaseEventsTest): - - def test_invalid_event(self): - self.assertIsNone(self._events.clear(0)) - - def test_valid_clear_returns_true(self): - self._events.set(events.CHANNEL0_OPENED) - self.assertTrue(self._events.clear(events.CHANNEL0_OPENED)) - - def test_unset_event_returns_false(self): - self.assertFalse(self._events.clear(events.CHANNEL0_OPENED)) - - -class EventInitTests(BaseEventsTest): - - def test_all_events_created(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event - for event in events.DESCRIPTIONS.keys(): - self.assertIsInstance(self._events._events[event], cls, - type(self._events._events[event])) - - -class EventIsSetTests(BaseEventsTest): - - def test_invalid_event(self): - self.assertIsNone(self._events.is_set(0)) - - def test_valid_is_set_returns_true(self): - self._events.set(events.CHANNEL0_CLOSED) - self.assertTrue(self._events.is_set(events.CHANNEL0_CLOSED)) - - def test_unset_event_returns_false(self): - self.assertFalse(self._events.is_set(events.CHANNEL0_OPENED)) - - -class EventSetTests(BaseEventsTest): - - def test_invalid_event(self): - self.assertIsNone(self._events.set(0)) - - def test_valid_set_returns_true(self): - self.assertTrue(self._events.set(events.CHANNEL0_CLOSED)) - - def test_already_set_event_returns_false(self): - self._events.set(events.CHANNEL0_OPENED) - self.assertFalse(self._events.set(events.CHANNEL0_OPENED)) - - -class EventWaitTests(BaseEventsTest): - - def test_invalid_event(self): - self.assertIsNone(self._events.wait(0)) - - def test_blocking_wait_returns_true(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event - with mock.patch.object(cls, 'wait') as mock_method: - mock_method.return_value = True - self.assertTrue(self._events.wait(events.CHANNEL0_CLOSED)) - - def test_blocking_wait_returns_false(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event - with mock.patch.object(cls, 'wait') as mock_method: - mock_method.return_value = False - self.assertFalse(self._events.wait(events.CHANNEL0_CLOSED, 1)) diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 64909ed..0000000 --- a/tests/helpers.py +++ /dev/null @@ -1,31 +0,0 @@ -try: - import unittest2 as unittest -except ImportError: - import 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 = connection.queue.Queue() - self.connection.open = True - self.connection.closed = False - self.channel = channel.Channel(1, {}, - self.connection._events, - self.connection._exceptions, - connection.queue.Queue(), - connection.queue.Queue(), 32768, - self.connection._io.write_trigger, - connection=self.connection) - self.channel._set_state(self.channel.OPEN) \ No newline at end of file diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py new file mode 100644 index 0000000..9a5e69e --- /dev/null +++ b/tests/integration/test_connection.py @@ -0,0 +1,28 @@ +import logging +import pathlib +import sys +import unittest + +from rabbitpy import connection + + +class ConnectionTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + logging.basicConfig(level=logging.DEBUG) + cls.os_environ = {} + path = ( + pathlib.Path(__file__).parent.parent.parent / 'build' / 'test.env' + ) + if not path.exists(): + sys.stderr.write('Failed to find test.env.file\n') + return + with path.open('r') as f: + for line in f: + line = line.removeprefix('export ') + name, _, value = line.strip().partition('=') + cls.os_environ[name] = value + + def test_connection(self): + with connection.Connection(self.os_environ['RABBITMQ_URL']) as conn: + self.assertIsNotNone(conn) diff --git a/tests/integration_tests.py b/tests/integration_tests.py deleted file mode 100644 index b102a3b..0000000 --- a/tests/integration_tests.py +++ /dev/null @@ -1,533 +0,0 @@ -import logging -import re -import os -import threading -import time -try: - import unittest2 as unittest -except ImportError: - import unittest -try: - from urllib import parse -except ImportError: - import urlparse as parse -import uuid - -import rabbitpy -from rabbitpy import exceptions -from rabbitpy import 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, - 'test.publish.consume {0}'.format(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 - assert False, '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 = '{}:{}@{}:{}'.format( - 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/mixins.py b/tests/mixins.py index f6ee27a..45f5236 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -3,7 +3,6 @@ class EnvironmentVariableMixin: - @classmethod def setUpClass(cls): cls.os_environ = {} @@ -13,7 +12,6 @@ def setUpClass(cls): return with path.open('r') as f: for line in f: - if line.startswith('export '): - line = line[7:] + line = line.removeprefix('export ') name, _, value = line.strip().partition('=') cls.os_environ[name] = value diff --git a/tests/test_amqp.py b/tests/test_amqp.py deleted file mode 100644 index 6fe716b..0000000 --- a/tests/test_amqp.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Test the rabbitpy.amqp class - -""" -import mock - -from rabbitpy import amqp, base, channel - -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_events.py b/tests/test_events.py index af84464..ac933d2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,6 +2,7 @@ Test the rabbitpy events class """ + import threading import unittest from unittest import mock @@ -10,7 +11,6 @@ class BaseEventsTest(unittest.TestCase): - def setUp(self): self._events = events.Events() @@ -19,7 +19,6 @@ def tearDown(self): class EventClearTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.clear(0)) @@ -32,15 +31,16 @@ def test_unset_event_returns_false(self): class EventInitTests(BaseEventsTest): - def test_all_events_created(self): for event in events.DESCRIPTIONS.keys(): - self.assertIsInstance(self._events._events[event], threading.Event, - type(self._events._events[event])) + self.assertIsInstance( + self._events._events[event], + threading.Event, + type(self._events._events[event]), + ) class EventIsSetTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.is_set(0)) @@ -53,7 +53,6 @@ def test_unset_event_returns_false(self): class EventSetTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.set(0)) @@ -66,7 +65,6 @@ def test_already_set_event_returns_false(self): class EventWaitTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.wait(0)) diff --git a/tests/test_exchange.py b/tests/test_exchange.py deleted file mode 100644 index f1007c1..0000000 --- a/tests/test_exchange.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Test the rabbitpy.exchange classes - -""" -import mock -from pamqp import 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_io.py b/tests/test_io.py index 4c8f45f..3ebc983 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -23,7 +23,6 @@ class TestCase(unittest.TestCase): - def setUp(self): """Set up a common IO instance for tests.""" self.events = events.Events() @@ -47,13 +46,12 @@ def tearDown(self): class TestIO(TestCase): - def setUp(self): """Set up a common IO instance for tests.""" super().setUp() self.io = io.IO( - 'localhost', 5672, False, {}, self.events, - self.exceptions) + 'localhost', 5672, False, {}, self.events, self.exceptions + ) self.io.add_channel(0, self.read_queue) def test_initialization(self): @@ -79,7 +77,8 @@ def test_on_data_received(self): b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' b'productS\x00\x00\x00\x08RabbitMQ\x07versio' b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' - b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce') + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce' + ) remaining, channel, frame, count = self.io._on_data_received(data_in) self.assertEqual(remaining, b'') self.assertEqual(channel, 0) @@ -96,8 +95,13 @@ def test_on_data_received_empty(self): def test_on_data_received_error(self): payload = struct.pack('>L', 42949) - data_in = b''.join([struct.pack('>BHI', 1, 0, len(payload)), - payload, constants.FRAME_END_CHAR]) + data_in = b''.join( + [ + struct.pack('>BHI', 1, 0, len(payload)), + payload, + constants.FRAME_END_CHAR, + ] + ) remaining, channel, frame, count = self.io._on_data_received(data_in) self.assertEqual(remaining, data_in) self.assertIsNone(channel) @@ -116,8 +120,13 @@ def test_on_error_already_closed(self): def test_connect_failure(self): instance = io.IO( - 'localhost', random.randint(1024, 32768), # noqa: S311 - False, {}, self.events, self.exceptions) + 'localhost', + random.randint(1024, 32768), + False, + {}, + self.events, + self.exceptions, + ) instance.start() connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 1) self.assertFalse(connected, 'Should not have connected') @@ -142,7 +151,7 @@ def test_get_ssl_context_verify_none(self): self.io._use_ssl = True self.io._ssl_options = { 'check_hostname': False, - 'verify': ssl.CERT_NONE + 'verify': ssl.CERT_NONE, } context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) @@ -154,7 +163,7 @@ def test_get_ssl_context_certs(self): 'cafile': DATA_PATH / 'ca.crt', 'certfile': DATA_PATH / 'client.crt', 'keyfile': DATA_PATH / 'client.key', - 'verify': ssl.CERT_REQUIRED + 'verify': ssl.CERT_REQUIRED, } context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) @@ -163,14 +172,13 @@ def test_get_ssl_context_certs(self): class MockServer(asyncio.Protocol): - - instances = [] + instances: typing.ClassVar[list['MockServer']] = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._expectation: typing.Optional[bytes] = None - self._response: typing.Optional[bytes] = None - self._transport: typing.Optional[asyncio.Transport] = None + self._expectation: bytes | None = None + self._response: bytes | None = None + self._transport: asyncio.Transport | None = None MockServer.instances.append(self) def connection_lost(self, exc): @@ -178,8 +186,9 @@ def connection_lost(self, exc): def connection_made(self, transport): self._transport = transport - LOGGER.debug('Connection from %r', - transport.get_extra_info('peername')) + LOGGER.debug( + 'Connection from %r', transport.get_extra_info('peername') + ) def data_received(self, data): LOGGER.debug('Received %r', data) @@ -200,18 +209,22 @@ def set_response(self, response: bytes): self._response = response -class MockServerTestCase(mixins.EnvironmentVariableMixin, - TestCase, - unittest.IsolatedAsyncioTestCase): - +class MockServerTestCase( + mixins.EnvironmentVariableMixin, TestCase, unittest.IsolatedAsyncioTestCase +): async def asyncSetUp(self): await super().asyncSetUp() self.server = await self._create_server() args = self._get_io_args() LOGGER.debug('Connection args: %r', args) - self.io = io.IO( - args['host'], args['port'], args['ssl'], args['ssl_options'], - self.events, self.exceptions) + self.io = io.IO( + args['host'], + args['port'], + args['ssl'], + args['ssl_options'], + self.events, + self.exceptions, + ) self.io.add_channel(0, self.read_queue) self.io.add_channel(1, self.read_queue) @@ -256,7 +269,6 @@ def _server_port(self): class MockServerConnectedTestCase(MockServerTestCase): - async def asyncSetUp(self): await super().asyncSetUp() self.io.start() @@ -279,7 +291,8 @@ async def test_on_read_ready(self): b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' b'productS\x00\x00\x00\x08RabbitMQ\x07versio' b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' - b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce') + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce' + ) await self.write_protocol_header() @@ -306,7 +319,8 @@ async def test_on_data_received_multiple_frames(self): b'productS\x00\x00\x00\x08RabbitMQ\x07versio' b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce\x01\x00' - b'\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce') + b'\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce' + ) await self.write_protocol_header() @@ -323,7 +337,8 @@ 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') mock_server.set_response( - b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00') + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00' + ) await self.write_protocol_header() @@ -338,7 +353,8 @@ async def test_remote_name(self): local = self.io._socket.getsockname() self.assertEqual( self.io.remote_name, - f'{local[0]}:{local[1]} -> {remote[0]}:{remote[1]}') + f'{local[0]}:{local[1]} -> {remote[0]}:{remote[1]}', + ) async def test_close(self): _mock_server = await self.get_mock_server() @@ -348,7 +364,6 @@ async def test_close(self): class EdgeCaseMockServerTestCase(MockServerTestCase): - @mock.patch('socket.getaddrinfo') async def test_error_on_getaddrinfo(self, mock_getaddrinfo): mock_getaddrinfo.side_effect = OSError('Foo') @@ -424,7 +439,6 @@ async def test_run_oserror(self): class SSLMockServerTestCase(MockServerTestCase): - async def asyncSetUp(self): await super().asyncSetUp() self.io.start() @@ -435,25 +449,28 @@ async def _create_server(): context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_verify_locations(DATA_PATH / 'ca.crt') context.load_cert_chain( - DATA_PATH / 'server.crt', - DATA_PATH / 'server.key') + DATA_PATH / 'server.crt', DATA_PATH / 'server.key' + ) loop = asyncio.get_running_loop() server = await loop.create_server(MockServer, '127.0.0.1', ssl=context) - LOGGER.debug("Mock server running on: %s", - server.sockets[0].getsockname()) + LOGGER.debug( + 'Mock server running on: %s', server.sockets[0].getsockname() + ) return server def _get_io_args(self): return url_parser.parse( f'amqps://127.0.0.1:{self._server_port}?' f'ssl_check_hostname=false&ssl_verify=ignore&' - f'cafile=tests%2Fdata%2Fca.crt') + f'cafile=tests%2Fdata%2Fca.crt' + ) async def test_on_data_received(self): mock_server = await self.get_mock_server() mock_server.set_expectation(b'AMQP\x00\x00\t\x01') mock_server.set_response( - b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00') + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00' + ) await self.write_protocol_header() @@ -465,6 +482,7 @@ async def test_on_data_received(self): class Error35(OSError): """Mock Exception for socket.error errno=35""" + def __init__(self, errno, *args): super().__init__(*args) self.errno = errno diff --git a/tests/test_message.py b/tests/test_message.py deleted file mode 100644 index 96b7ff7..0000000 --- a/tests/test_message.py +++ /dev/null @@ -1,506 +0,0 @@ - # -*- coding: utf-8 -*- -""" -Test the rabbitpy.message.Message class - -""" -import datetime -import json -import logging -import time -import uuid - -import mock -from pamqp import body -from pamqp import header -from pamqp import specification - -from rabbitpy import channel -from rabbitpy import exceptions -from rabbitpy import exchange -from rabbitpy import message - -from tests import helpers - -logging.basicConfig(level=logging.DEBUG) - - -class TestCreation(helpers.TestCase): - - def setUp(self): - super(TestCreation, self).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(TestCreationWithDictBody, self).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(TestCreationWithStructTimeTimestamp, self).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(TestCreationWithFloatTimestamp, self).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(TestCreationWithIntTimestamp, self).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(TestCreationWithNoneTimestamp, self).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(TestCreationWithStrTimestamp, self).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(TestCreationWithDictBodyAndProperties, self).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(TestNonOpinionatedCreation, self).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(TestWithPropertiesCreation, self).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.utcnow(), - '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(TestDeliveredMessageObject, self).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(TestNonDeliveredMessageObject, self).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'], dict()) - - 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(TestPublishing, self).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(TestJSONDeserialization, self).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(TestPublishingUnicode, self).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(TestPublisherConfirms, self).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 deleted file mode 100644 index 3152141..0000000 --- a/tests/test_queue.py +++ /dev/null @@ -1,429 +0,0 @@ -""" -Test the rabbitpy.amqp_queue classes - -""" -import mock -from pamqp import specification - -from rabbitpy import amqp_queue -from rabbitpy import channel -from rabbitpy import exceptions -from rabbitpy import 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_unicode(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_exchange=unicode('dlx-name')) - self.assertIsInstance(queue.dead_letter_exchange, unicode) - - 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_unicode(self): - routing_key = unicode('routing-key') - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key=routing_key) - self.assertIsInstance(queue.dead_letter_routing_key, unicode) - - 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(QueueAssignmentTests, self).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(WriteFrameTests, self).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_list_invokes_write_frame_with_queue_declare(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 deleted file mode 100644 index cd7b685..0000000 --- a/tests/test_tx.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Test the rabbitpy.tx classes - -""" -import mock -from pamqp import 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') as 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') as 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/test_url_parser.py b/tests/test_url_parser.py index 57bba68..1c033ca 100644 --- a/tests/test_url_parser.py +++ b/tests/test_url_parser.py @@ -5,7 +5,6 @@ class URLParsingTestCase(unittest.TestCase): - def test_default_url(self): args = url_parser.parse() self.assertEqual(args['host'], 'localhost') diff --git a/tests/utils_tests.py b/tests/utils_tests.py deleted file mode 100644 index 6aacc4f..0000000 --- a/tests/utils_tests.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Test the rabbitpy utils module - -""" -try: - import unittest2 as unittest -except ImportError: - import unittest -import sys -from rabbitpy import utils - -# 3 Unicode Compatibility hack -if sys.version_info[0] == 3: - 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), '//') diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6a12700 --- /dev/null +++ b/uv.lock @@ -0,0 +1,416 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "build" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1d/ab15c8ac57f4ee8778d7633bc6685f808ab414437b8644f555389cdc875e/build-1.4.2.tar.gz", hash = "sha256:35b14e1ee329c186d3f08466003521ed7685ec15ecffc07e68d706090bf161d1", size = 83433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/57/3b7d4dd193ade4641c865bc2b93aeeb71162e81fc348b8dad020215601ed/build-1.4.2-py3-none-any.whl", hash = "sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88", size = 24643 }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381 }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880 }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303 }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218 }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326 }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267 }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430 }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017 }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080 }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843 }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802 }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707 }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880 }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816 }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483 }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554 }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908 }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419 }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159 }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270 }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538 }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821 }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191 }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337 }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404 }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903 }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780 }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093 }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900 }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515 }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576 }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942 }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935 }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541 }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780 }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912 }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165 }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908 }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873 }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030 }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694 }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469 }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112 }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923 }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540 }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262 }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617 }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912 }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987 }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416 }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558 }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163 }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981 }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604 }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321 }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502 }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688 }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851 }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104 }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621 }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953 }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992 }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503 }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852 }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161 }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021 }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858 }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823 }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099 }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638 }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295 }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360 }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174 }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739 }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351 }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612 }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985 }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107 }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513 }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089 }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982 }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579 }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316 }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427 }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745 }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146 }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254 }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276 }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394 }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848 }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216 }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, +] + +[[package]] +name = "python-discovery" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "rabbitpy" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "pamqp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build" }, + { name = "coverage", extra = ["toml"] }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "pamqp", specifier = ">=3.0,<4.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "build" }, + { name = "coverage", extras = ["toml"] }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394 }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693 }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044 }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135 }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041 }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987 }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057 }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613 }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557 }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440 }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963 }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484 }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426 }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125 }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959 }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893 }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084 }, +] From 0ebc1cc62d8a580156b4880359ab867a2fbde667 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 20:36:12 -0400 Subject: [PATCH 38/59] Apply ruff formatting to docs/conf.py Co-Authored-By: Gavin M. Roy --- docs/conf.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 48ce46a..32f9826 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,25 +6,29 @@ sys.path.insert(0, '../') -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', - 'sphinx.ext.intersphinx'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.viewcode', + 'sphinx.ext.autosummary', + 'sphinx.ext.intersphinx', +] templates_path = ['_templates'] source_suffix = '.rst' master_doc = 'index' project = 'rabbitpy' copyright = '2013 - 2025, Gavin M. Roy' -import rabbitpy # noqa: E402 I001 +import rabbitpy # noqa: E402 + release = rabbitpy.__version__ version = '.'.join(release.split('.')[0:1]) exclude_patterns = ['_build'] pygments_style = 'sphinx' -intersphinx_mapping = {'pamqp': ('https://pamqp.readthedocs.org/en/latest/', - None), - 'python': ('https://docs.python.org/3/', None)} +intersphinx_mapping = { + 'pamqp': ('https://pamqp.readthedocs.org/en/latest/', None), + 'python': ('https://docs.python.org/3/', None), +} html_theme = 'default' html_static_path = ['_static'] From 79f2f8d3b8ab4e838100ce9f0891dafdad0ff7ea Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 20:45:00 -0400 Subject: [PATCH 39/59] Replace Sphinx docs with mkdocs + GitHub Pages - Add mkdocs.yml with mkdocs-material theme - Convert RST docs to Markdown (index, history, threads, API, examples) - Add API reference pages using mkdocstrings - Add docs dependency-group to pyproject.toml - Add .github/workflows/docs.yaml to deploy to gh-pages on push to main - Remove Sphinx conf.py, Makefile, and all RST files - Regenerate uv.lock to include docs group packages Co-Authored-By: Gavin M. Roy --- .github/workflows/docs.yaml | 27 ++ CLAUDE.md | 3 +- docs/Makefile | 153 ------- docs/api/amqp.rst | 30 -- docs/api/channel.rst | 28 -- docs/api/connection.md | 55 +++ docs/api/connection.rst | 29 -- docs/api/exceptions.md | 99 ++++ docs/api/exceptions.rst | 38 -- docs/api/exchange.rst | 42 -- docs/api/message.rst | 9 - docs/api/queue.rst | 44 -- docs/api/tx.rst | 33 -- docs/conf.py | 35 -- docs/examples/consumer.md | 24 + docs/examples/consumer.rst | 20 - docs/examples/getter.md | 18 + docs/examples/getter.rst | 16 - docs/examples/ha_queues.rst | 10 - docs/examples/publisher.md | 29 ++ docs/examples/publisher_confirms.rst | 24 - docs/examples/transactional_publisher.rst | 23 - docs/history.md | 123 +++++ docs/history.rst | 166 ------- docs/index.md | 43 ++ docs/index.rst | 75 ---- docs/simple.rst | 16 - docs/threads.md | 68 +++ docs/threads.rst | 94 ---- mkdocs.yml | 56 +++ pyproject.toml | 5 + uv.lock | 520 ++++++++++++++++++++++ 32 files changed, 1069 insertions(+), 886 deletions(-) create mode 100644 .github/workflows/docs.yaml delete mode 100644 docs/Makefile delete mode 100644 docs/api/amqp.rst delete mode 100644 docs/api/channel.rst create mode 100644 docs/api/connection.md delete mode 100644 docs/api/connection.rst create mode 100644 docs/api/exceptions.md delete mode 100644 docs/api/exceptions.rst delete mode 100644 docs/api/exchange.rst delete mode 100644 docs/api/message.rst delete mode 100644 docs/api/queue.rst delete mode 100644 docs/api/tx.rst delete mode 100644 docs/conf.py create mode 100644 docs/examples/consumer.md delete mode 100644 docs/examples/consumer.rst create mode 100644 docs/examples/getter.md delete mode 100644 docs/examples/getter.rst delete mode 100644 docs/examples/ha_queues.rst create mode 100644 docs/examples/publisher.md delete mode 100644 docs/examples/publisher_confirms.rst delete mode 100644 docs/examples/transactional_publisher.rst create mode 100644 docs/history.md delete mode 100644 docs/history.rst create mode 100644 docs/index.md delete mode 100644 docs/index.rst delete mode 100644 docs/simple.rst create mode 100644 docs/threads.md delete mode 100644 docs/threads.rst create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..f2c9fa1 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,27 @@ +name: Docs +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'rabbitpy/**' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --group docs + + - name: Deploy docs + run: uv run mkdocs gh-deploy --force diff --git a/CLAUDE.md b/CLAUDE.md index ce8e040..d23521c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,10 +5,11 @@ A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library. ## Development ```bash -uv sync --all-groups # Install dependencies +uv sync --all-groups # Install all dependencies (dev + docs) uv run coverage run # Run tests with coverage uv run coverage report # View coverage report uv run pre-commit run -a # Run linting +uv run mkdocs serve # Serve docs locally at http://127.0.0.1:8000 ``` ## Integration Tests diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 6f6d8b7..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,153 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rabbitpy.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rabbitpy.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/rabbitpy" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rabbitpy" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/api/amqp.rst b/docs/api/amqp.rst deleted file mode 100644 index c5a6e2d..0000000 --- a/docs/api/amqp.rst +++ /dev/null @@ -1,30 +0,0 @@ -AMQP Adapter -============ -While the core rabbitpy API strives to provide an easy to use, Pythonic interface -for RabbitMQ, some developers may prefer a less opinionated AMQP interface. The -:py:class:`rabbitpy.AMQP` adapter provides a more traditional AMQP client library -API seen in libraries like `pika `_. - -.. versionadded:: 0.26 - -Example -------- -The following example will connect to RabbitMQ and use the :py:class:`rabbitpy.AMQP` -adapter to consume and acknowledge messages. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as conn: - with conn.channel() as channel: - amqp = rabbitpy.AMQP(channel) - - for message in amqp.basic_consume('queue-name'): - print(message) - -API Documentation ------------------ - -.. autoclass:: rabbitpy.AMQP - :members: diff --git a/docs/api/channel.rst b/docs/api/channel.rst deleted file mode 100644 index e782480..0000000 --- a/docs/api/channel.rst +++ /dev/null @@ -1,28 +0,0 @@ -Channel -======= -A :class:`Channel` is created on an active connection using the :meth:`Connection.channel() ` method. Channels can act as normal Python objects: - -.. code:: python - - conn = rabbitpy.Connection() - chan = conn.channel() - chan.enable_publisher_confirms() - chan.close() - -or as a Python context manager (See :pep:`0343`): - -.. code:: python - - with rabbitpy.Connection() as conn: - with conn.channel() as chan: - chan.enable_publisher_confirms() - -When they are used as a context manager with the `with` statement, when your code exits the block, the channel will automatically close, issuing a clean shutdown with RabbitMQ via the ``Channel.Close`` RPC request. - -You should be aware that if you perform actions on a channel with exchanges, queues, messages or transactions that RabbitMQ does not like, it will close the channel by sending an AMQP ``Channel.Close`` RPC request to your application. Upon receipt of such a request, rabbitpy will raise the :doc:`appropriate exception ` referenced in the request. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Channel - :members: diff --git a/docs/api/connection.md b/docs/api/connection.md new file mode 100644 index 0000000..7a6ee51 --- /dev/null +++ b/docs/api/connection.md @@ -0,0 +1,55 @@ +# Connection + +rabbitpy Connection objects are used to connect to RabbitMQ. They provide a +thread-safe connection to RabbitMQ that is used to authenticate and send all +channel based RPC commands over. Connections use +[AMQP URI syntax](http://www.rabbitmq.com/uri-spec.html) for specifying all +of the connection information, including any connection negotiation options, +such as the heartbeat interval. + +## Usage + +A `Connection` can be used as a normal Python object: + +```python +conn = rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2F') +conn.close() +``` + +Or as a Python context manager: + +```python +with rabbitpy.Connection() as conn: + with conn.channel() as channel: + # use channel + pass +``` + +When used as a context manager, the connection is automatically closed when +the block exits. + +If RabbitMQ remotely closes the connection, rabbitpy will raise the appropriate +exception (see [Exceptions](exceptions.md)). + +If heartbeats are enabled (default: 5 minutes) and RabbitMQ does not send a +heartbeat in >= 2 heartbeat intervals, a `ConnectionResetException` will be raised. + +## Connection URL Parameters + +The connection URL follows standard AMQP URI syntax: + +``` +amqp[s]://[user:password@]host[:port]/[vhost][?key=value...] +``` + +Common query parameters: + +| Parameter | Default | Description | +| ----------- | ------- | --------------------------------- | +| `heartbeat` | `300` | Heartbeat interval in seconds | +| `channel_max` | `0` | Maximum number of channels | +| `frame_max` | `131072` | Maximum frame size in bytes | + +## API + +::: rabbitpy.connection.Connection diff --git a/docs/api/connection.rst b/docs/api/connection.rst deleted file mode 100644 index bd24bba..0000000 --- a/docs/api/connection.rst +++ /dev/null @@ -1,29 +0,0 @@ -Connection -========== -rabbitpy Connection objects are used to connect to RabbitMQ. They provide a thread-safe connection to RabbitMQ that is used to authenticate and send all channel based RPC commands over. Connections use `AMQP URI syntax `_ for specifying the all of the connection information, including any connection negotiation options, such as the heartbeat interval. For more information on the various query parameters that can be specified, see the `official documentation `_. - -A :class:`Connection` is a normal python object that you use: - -.. code:: python - - conn = rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2F') - conn.close() - -or it can be used as a Python context manager (See :pep:`0343`): - -.. code:: python - - with rabbitpy.Connection() as conn: - # Foo - -When it is used as a context manager with the `with` statement, when your code exits the block, the connection will automatically close. - -If RabbitMQ remotely closes your connection via the AMQP `Connection.Close` RPC request, rabbitpy will raise the :doc:`appropriate exception ` referenced in the request. - -If heartbeats are enabled (default: 5 minutes) and RabbitMQ does not send a heartbeat request in >= 2 heartbeat intervals, a :py:class:`ConnectionResetException ` will be raised. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Connection - :members: diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md new file mode 100644 index 0000000..d41d8c5 --- /dev/null +++ b/docs/api/exceptions.md @@ -0,0 +1,99 @@ +# Exceptions + +rabbitpy contains two types of exceptions: exceptions specific to rabbitpy +and exceptions raised as a result of a Channel or Connection closure from +RabbitMQ. + +## Example + +```python +import rabbitpy + +try: + with rabbitpy.Connection() as connection: + with connection.channel() as channel: + queue = rabbitpy.Queue(channel, 'exception-test') + queue.durable = True + queue.declare() + queue.durable = False + queue.declare() # raises AMQPPreconditionFailed +except rabbitpy.exceptions.AMQPPreconditionFailed: + print('Queue already exists with different parameters') +``` + +## rabbitpy Exceptions + +::: rabbitpy.exceptions.RabbitpyException + +::: rabbitpy.exceptions.ActionException + +::: rabbitpy.exceptions.ChannelClosedException + +::: rabbitpy.exceptions.ConnectionException + +::: rabbitpy.exceptions.ConnectionClosed + +::: rabbitpy.exceptions.ConnectionResetException + +::: rabbitpy.exceptions.MessageReturnedException + +::: rabbitpy.exceptions.NoActiveTransactionError + +::: rabbitpy.exceptions.NotConsumingError + +::: rabbitpy.exceptions.NotSupportedError + +::: rabbitpy.exceptions.ReceivedOnClosedChannelException + +::: rabbitpy.exceptions.RemoteCancellationException + +::: rabbitpy.exceptions.RemoteClosedChannelException + +::: rabbitpy.exceptions.RemoteClosedException + +::: rabbitpy.exceptions.TooManyChannelsError + +::: rabbitpy.exceptions.UnexpectedResponseError + +## AMQP Exceptions + +These exceptions are raised when RabbitMQ sends a channel or connection close +with a specific AMQP reply code. + +::: rabbitpy.exceptions.AMQPException + +::: rabbitpy.exceptions.AMQPAccessRefused + +::: rabbitpy.exceptions.AMQPChannelError + +::: rabbitpy.exceptions.AMQPCommandInvalid + +::: rabbitpy.exceptions.AMQPConnectionForced + +::: rabbitpy.exceptions.AMQPContentTooLarge + +::: rabbitpy.exceptions.AMQPFrameError + +::: rabbitpy.exceptions.AMQPInternalError + +::: rabbitpy.exceptions.AMQPInvalidPath + +::: rabbitpy.exceptions.AMQPNoConsumers + +::: rabbitpy.exceptions.AMQPNoRoute + +::: rabbitpy.exceptions.AMQPNotAllowed + +::: rabbitpy.exceptions.AMQPNotFound + +::: rabbitpy.exceptions.AMQPNotImplemented + +::: rabbitpy.exceptions.AMQPPreconditionFailed + +::: rabbitpy.exceptions.AMQPResourceError + +::: rabbitpy.exceptions.AMQPResourceLocked + +::: rabbitpy.exceptions.AMQPSyntaxError + +::: rabbitpy.exceptions.AMQPUnexpectedFrame diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst deleted file mode 100644 index c8fd55f..0000000 --- a/docs/api/exceptions.rst +++ /dev/null @@ -1,38 +0,0 @@ -Exceptions -========== -rabbitpy contains two types of exceptions, exceptions that are specific to rabbitpy and exceptions that are raised as a result of a Channel or Connection closure from RabbitMQ. These exceptions will be raised to let you know when you have performed an action like redeclared a pre-existing queue with different values. Consider the following example: - -.. code:: python - - >>> import rabbitpy - >>> - >>> with rabbitpy.Connection() as connection: - ... with connection.channel() as channel: - ... queue = rabbitpy.Queue(channel, 'exception-test') - ... queue.durable = True - ... queue.declare() - ... queue.durable = False - ... queue.declare() - ... - Traceback (most recent call last): - File "", line 7, in - File "rabbitpy/connection.py", line 131, in __exit__ - self._shutdown_connection() - File "rabbitpy/connection.py", line 469, in _shutdown_connection - self._channels[chan_id].close() - File "rabbitpy/channel.py", line 124, in close - super(Channel, self).close() - File "rabbitpy/base.py", line 185, in close - self.rpc(frame_value) - File "rabbitpy/base.py", line 199, in rpc - self._write_frame(frame_value) - File "rabbitpy/base.py", line 311, in _write_frame - raise exception - rabbitpy.exceptions.AMQPPreconditionFailed: - -In this example, the channel that was created on the second line was closed and RabbitMQ is raising the :class:`AMQPPreconditionFailed ` exception via RPC sent to your application using the AMQP Channel.Close method. - -.. automodule:: rabbitpy.exceptions - :members: - :private-members: - :undoc-members: diff --git a/docs/api/exchange.rst b/docs/api/exchange.rst deleted file mode 100644 index 7b9686f..0000000 --- a/docs/api/exchange.rst +++ /dev/null @@ -1,42 +0,0 @@ -Exchange -======== -The :class:`Exchange ` class is used to work with RabbitMQ exchanges on an open channel. The following example shows how you can create an exchange using the :class:`rabbitpy.Exchange` class. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - -In addition, there are four convenience classes (:class:`DirectExchange `, :class:`FanoutExchange `, :class:`HeadersExchange `, and :class:`TopicExchange `) for creating each built-in exchange type in RabbitMQ. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Exchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.DirectExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.FanoutExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.HeadersExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.TopicExchange - :members: - :inherited-members: - :member-order: bysource diff --git a/docs/api/message.rst b/docs/api/message.rst deleted file mode 100644 index f6188e7..0000000 --- a/docs/api/message.rst +++ /dev/null @@ -1,9 +0,0 @@ -Message -======= -The :class:`Message ` class is used to create messages that you intend to publish to RabbitMQ and is created when a message is received by RabbitMQ by a consumer or as the result of a :meth:`Queue.get() ` request. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Message - :members: diff --git a/docs/api/queue.rst b/docs/api/queue.rst deleted file mode 100644 index 6ed18b8..0000000 --- a/docs/api/queue.rst +++ /dev/null @@ -1,44 +0,0 @@ -Queue -===== -The :class:`Queue ` class is used to work with RabbitMQ queues on an open channel. The following example shows how you can create a queue using the :meth:`Queue.declare ` method. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - queue = rabbitpy.Queue(channel, 'my-queue') - queue.durable = True - queue.declare() - -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() - -.. warning:: If you use either the :py:class:`Queue` as an iterator method or :py:meth:`Queue.consume` method of consuming messages in PyPy, - you must manually invoke :py:meth:`Queue.stop_consuming`. This is due to PyPy not predictably cleaning up after the generator - used for allowing the iteration over messages. Should your code want to test to see if the code is being executed in PyPy, - you can evaluate the boolean ``rabbitpy.PYPY`` constant value. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Queue - :members: - :special-members: diff --git a/docs/api/tx.rst b/docs/api/tx.rst deleted file mode 100644 index b2a9399..0000000 --- a/docs/api/tx.rst +++ /dev/null @@ -1,33 +0,0 @@ -Transactions -============ -The :class:`Tx ` or transaction class implements transactional functionality with RabbitMQ and allows for any AMQP command to be issued, then committed or rolled back. - -It can be used as a normal Python object: - -.. code:: python - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - tx = rabbitpy.Tx(channel) - tx.select() - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - tx.commit() - -Or as a context manager (See :pep:`0343`) where the transaction will automatically be started and committed for you: - -.. code:: python - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - with rabbitpy.Tx(channel) as tx: - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - -In the event of an exception exiting the block when used as a context manager, the transaction will be rolled back for you automatically. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Tx - :members: diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 32f9826..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,35 +0,0 @@ -# -# rabbitpy documentation build configuration file, created by -# sphinx-quickstart on Wed Mar 27 18:31:37 2013. - -import sys - -sys.path.insert(0, '../') - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', - 'sphinx.ext.intersphinx', -] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' -project = 'rabbitpy' -copyright = '2013 - 2025, Gavin M. Roy' - -import rabbitpy # noqa: E402 - -release = rabbitpy.__version__ -version = '.'.join(release.split('.')[0:1]) -exclude_patterns = ['_build'] -pygments_style = 'sphinx' -intersphinx_mapping = { - 'pamqp': ('https://pamqp.readthedocs.org/en/latest/', None), - 'python': ('https://docs.python.org/3/', None), -} - -html_theme = 'default' -html_static_path = ['_static'] -htmlhelp_basename = 'rabbitpydoc' diff --git a/docs/examples/consumer.md b/docs/examples/consumer.md new file mode 100644 index 0000000..b14938f --- /dev/null +++ b/docs/examples/consumer.md @@ -0,0 +1,24 @@ +# Message Consumer + +The following example subscribes to a queue named `test` and consumes messages +until CTRL-C is pressed: + +```python +#!/usr/bin/env python +import logging + +import rabbitpy + +URL = 'amqp://guest:guest@localhost:5672/%2f?heartbeat=15' + +logging.basicConfig(level=logging.INFO) + +with rabbitpy.Connection(URL) as conn: + with conn.channel() as channel: + try: + for message in rabbitpy.Queue(channel, 'test'): + message.pprint(True) + message.ack() + except KeyboardInterrupt: + logging.info('Exited consumer') +``` diff --git a/docs/examples/consumer.rst b/docs/examples/consumer.rst deleted file mode 100644 index 0de17e0..0000000 --- a/docs/examples/consumer.rst +++ /dev/null @@ -1,20 +0,0 @@ -Message Consumer -================ -The following example will subscribe to a queue named "example" and consume messages -until CTRL-C is pressed:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - - # Exit on CTRL-C - try: - # Consume the message - for message in queue: - message.pprint(True) - message.ack() - - except KeyboardInterrupt: - print 'Exited consumer' diff --git a/docs/examples/getter.md b/docs/examples/getter.md new file mode 100644 index 0000000..6744d15 --- /dev/null +++ b/docs/examples/getter.md @@ -0,0 +1,18 @@ +# Message Getter + +The following example gets messages one at a time from the `example` queue, +using `len(queue)` to check the queue depth: + +```python +#!/usr/bin/env python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, 'example') + while len(queue) > 0: + message = queue.get() + message.pprint(True) + message.ack() + print(f'There are {len(queue)} more messages in the queue') +``` diff --git a/docs/examples/getter.rst b/docs/examples/getter.rst deleted file mode 100644 index ca56388..0000000 --- a/docs/examples/getter.rst +++ /dev/null @@ -1,16 +0,0 @@ -Message Getter -============== -The following example will get a single message at a time from the "example" queue -as long as there are messages in it. It uses :code:`len(queue)` to check the current -queue depth while it is looping:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - while len(queue) > 0: - message = queue.get() - message.pprint(True) - message.ack() - print('There are {} more messages in the queue'.format(len(queue))) diff --git a/docs/examples/ha_queues.rst b/docs/examples/ha_queues.rst deleted file mode 100644 index be616ed..0000000 --- a/docs/examples/ha_queues.rst +++ /dev/null @@ -1,10 +0,0 @@ -Declaring HA Queues -=================== -The following example will create a HA :py:meth:`queue ` on each node in a RabbitMQ cluster.:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - queue.ha_declare() diff --git a/docs/examples/publisher.md b/docs/examples/publisher.md new file mode 100644 index 0000000..fcab6c9 --- /dev/null +++ b/docs/examples/publisher.md @@ -0,0 +1,29 @@ +# Publisher + +The following example connects to RabbitMQ and publishes a message to an exchange: + +```python +#!/usr/bin/env python +import datetime +import uuid + +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + exchange = rabbitpy.Exchange(channel, 'example_exchange') + exchange.declare() + + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'delivery_mode': 1, + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': str(uuid.uuid4()), + }, + ) + message.publish(exchange, 'test-routing-key') +``` diff --git a/docs/examples/publisher_confirms.rst b/docs/examples/publisher_confirms.rst deleted file mode 100644 index 96cfeb2..0000000 --- a/docs/examples/publisher_confirms.rst +++ /dev/null @@ -1,24 +0,0 @@ -Mandatory Publishing -==================== -The following example uses RabbitMQ's Publisher Confirms feature to allow for validation -that the message was successfully published:: - - import rabbitpy - - # Connect to RabbitMQ on localhost, port 5672 as guest/guest - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - - # Open the channel to communicate with RabbitMQ - with conn.channel() as channel: - - # Turn on publisher confirmations - channel.enable_publisher_confirms() - - # Create the message to publish - message = rabbitpy.Message(channel, 'message body value') - - # Publish the message, looking for the return value to be a bool True/False - if message.publish('test_exchange', 'test-routing-key', mandatory=True): - print 'Message publish confirmed by RabbitMQ' - else: - print 'RabbitMQ indicates message publishing failure' diff --git a/docs/examples/transactional_publisher.rst b/docs/examples/transactional_publisher.rst deleted file mode 100644 index 227256e..0000000 --- a/docs/examples/transactional_publisher.rst +++ /dev/null @@ -1,23 +0,0 @@ -Transactional Publisher -======================== -The following example uses RabbitMQ's Transactions feature to send the message, -then roll it back:: - - import rabbitpy - - # Connect to RabbitMQ on localhost, port 5672 as guest/guest - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - - # Open the channel to communicate with RabbitMQ - with conn.channel() as channel: - - # Start the transaction - tx = rabbitpy.Tx(channel) - tx.select() - - # Create the message to publish & publish it - message = rabbitpy.Message(channel, 'message body value') - message.publish('test_exchange', 'test-routing-key') - - # Rollback the transaction - tx.rollback() diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..f6fd45e --- /dev/null +++ b/docs/history.md @@ -0,0 +1,123 @@ +# Version History + +## 3.0.0 — *2024-05-08* + +- Change to use `ssl.SSLContext` since `ssl.wrap_socket` was deprecated in Python 3.7 and removed in 3.12 +- Drops support for Python < 3.8 +- Adds support for Python 3.10, 3.11, and 3.12 +- Change tests over to use `coverage` instead of `nose` + +## 2.0.1 — *2019-08-06* + +- Fixed an issue with the IO loop poller on MacOS (#111) + +## 2.0.0 — *2019-04-19* + +- Updated to use pamqp>=2.3,<3: + - Field table keys are now strings and no longer bytes + - In Python 2.7 if a short-string has UTF-8 characters, it will be a unicode object +- Field-table integer encoding changes +- Drops support for Python < 3.4 +- Adds support for Python 3.6 and 3.7 + +## 1.0.0 — *2016-10-27* + +- Reworked Heartbeat logic to send a heartbeat every `interval / 2` seconds when data has not been written to the socket (#70, #74, #98, #99) +- Improved performance when consuming large messages (#104) — *Jelle Aalbers* +- Allow for username and password to be default again (#96, #97) — *Grzegorz Śliwiński* +- Cleanup of Connection and Channel teardown (#103) + +## 0.27.1 — *2016-05-12* + +- Fix a bug where the IO write trigger socketpair is not being cleaned up on close + +## 0.27.0 — *2016-05-11* + +- Added new `SimpleChannel` class +- Exception formatting changes +- Thread locking optimizations +- Connection shutdown cleanup +- `Message` now assigns a UTC timestamp instead of local timestamp if requested (#94) +- Unquote username and password in URI (#93) — *sunbit* +- Connections now allow for a configurable timeout (#84, #85) — *vmarkovtsev* +- Bugfix for `basic_publish` (#92) — *canardleteer* +- Added `args` property to `Connection` (#88) — *vmarkovtsev* +- Fix locale in connection setup from causing hanging (#87) — *vmarkovtsev* +- Fix heartbeat behavior (#69, #70, #74) +- Cancel consuming in case of exceptions (#68) — *kmelnikov* +- Documentation correction (#79) — *jonahbull* + +## 0.26.2 — *2015-03-17* + +- Fix behavior for Basic.Return frames sent from RabbitMQ +- Pin pamqp 1.6.1 fixing an issue with max-channels + +## 0.26.1 — *2015-03-09* + +- Add the ability to interrupt rabbitpy when waiting on a frame (#38) +- Use a custom base class for all Exceptions (#57) — *Jeremy Tillman* +- Fix for consumer example in documentation (#60) — *Michael Becker* +- Add `rabbitpy.amqp` module for unopinionated access to AMQP API +- Refactor how client side heartbeat checking is managed +- Address an issue when client side channel max count is not set +- Clean up handling of remote channel and connection closing +- Fix URI query parameter names to match AMQP URI spec +- PYPY behavior fixes related to garbage collection + +## 0.25.0 — *2014-12-16* + +- Acquire a lock when creating a new channel to fix multi-threaded channel creation (#56) +- Add client side heartbeat checking (#55) +- Fix a bug where Basic.Nack checking was checking for the wrong string +- Add support for Python3 memoryviews for the message body (#50) +- Improve Python3 behavior in `maybe_utf8_encode` + +## 0.24.0 — *2014-12-12* + +- Update to reflect changes in pamqp 1.6.0 + +## 0.23.0 — *2014-11-05* + +- Fix a bug where message body length was assigned before converting unicode to bytes (#49) +- Fix the automatic coercion of header types to UTF-8 encoded bytes (#49) +- Raise `TypeError` if a timestamp property cannot be converted + +## 0.22.0 — *2014-11-04* + +- Address an issue when RabbitMQ is configured with a max-frame-size of 0 (#48) +- Do not lose the traceback when exiting a context manager due to an exception (#46) +- Adds server capability checking in `Channel` methods +- Pin pamqp version range to >= 1.4, < 2.0 + +## 0.21.1 — *2014-10-23* + +- Clean up KQueue issues (#44) +- Remove sockets from KQueue when in error state +- Handle socket connect errors more cleanly (#44) + +## 0.21.0 — *2014-10-21* + +- Address a possible edge case where message frames can be interspersed when publishing in a multi-threaded environment +- Add exception handling around select.error (#43) +- Add a new `opinionated` flag in `Message` construction +- Add wheel distribution + +## 0.20.0 — *2014-10-01* + +- Added support for KQueue and Poll in IOLoop +- Fixed issues with publishing large messages (#37) +- Add exchange property to `Message` (#40) +- Add out-of-band consumer cancellation with `Queue.stop_consuming()` (#38, #39) +- Significantly increase test coverage + +## Earlier Releases + +- **0.19.0** — Fix the socket read/write buffer size (#35) +- **0.18.1** — Fix unicode message body encoding in Python 2 +- **0.18.0** — Make IO thread daemonic; add `Message.redelivered` property +- **0.17.0** — Refactor cross-thread communication for RabbitMQ invoked RPC methods +- **0.16.0** — Fix an issue with `no_ack=True` consumer cancellation; fix exchange and queue unbinding +- **0.15.0** — Change default durability for Exchange and Queue to False +- **0.14.0** — Add support for `authentication_failure_close`; add consumer priorities +- **0.13.0** — Validate heartbeat is always an integer +- **< 0.5.0** — Previously called *rmqid* diff --git a/docs/history.rst b/docs/history.rst deleted file mode 100644 index 3b75559..0000000 --- a/docs/history.rst +++ /dev/null @@ -1,166 +0,0 @@ -Version History ---------------- -- 3.0.0 - released *2024-05-08* - - Change to use `ssl.SSLContext` since `ssl.wrap_socket` was deprecated in Python 3.7 and removed in 3.12 - - Drops support for Python < 3.8 - - Adds support for Python 3.10, 3.11 and 3.12 - - Change tests over to use `coverage` instead of `nose` -- 2.0.1 - released *2019-08-06* - - Fixed an issue with the IO loop poller on MacOS (#111) -- 2.0.0 - released *2019-04-19* - - Updated to use pamqp>=2.3,<3 which has the following implications: - - Field table keys are now strings and no longer bytes. This may be a breaking change means in Python3 keys will always be type str for short strings. This includes frame values and field table values. - - In Python 2.7 if a short-string (key, frame field value, etc) has UTF-8 characters in it, it will be a unicode object. - - field-table integer encoding changes - - Drops support for Python < 3.4 - - Adds support for Python 3.6 and 3.7 -- 1.0.0 - released *2016-10-27* - - Reworked Heartbeat logic to send a heartbeat every ``interval / 2`` seconds when data has not been written to the socket (#70, #74, #98, #99) - - Improved performance when consuming large mesages (#104) - `Jelle Aalbers `_ - - Allow for username and password to be default again (#96, #97) - `Grzegorz Śliwiński `_ - - Cleanup of Connection and Channel teardown (#103) -- 0.27.1 - released *2016-05-12* - - Fix a bug where the IO write trigger socketpair is not being cleaned up on close -- 0.27.0 - released *2016-05-11* - - Added new :class:`~rabbitpy.simple.SimpleChannel` class - - Exception formatting changes - - Thread locking optimizations - - Connection shutdown cleanup - - :class:`~rabbitpy.message.Message` now assigns a UTC timestamp instead of local timestamp to the AMQP message property if requested (#94) - - Unquote username and password in URI (#93) - `sunbit `_ - - Connections now allow for a configurable timeout (#84, #85) - `vmarkovtsev `_ - - Bugfix for :meth:`~rabbitpy.amqp.AMQP.basic_publish` (#92) - `canardleteer `_ - - Added ``args`` property to :class:`~rabbitpy.connection.Connection` (#88) - `vmarkovtsev `_ - - Fix locale in connection setup from causing hanging (#87) - `vmarkovtsev `_ - - Fix heartbeat behavior (#69, #70, #74) - - Cancel consuming in case of exceptions (#68) - `kmelnikov `_ - - Documentation correction (#79) - `jonahbull `_ -- 0.26.2 - released *2015-03-17* - - Fix behavior for Basic.Return frames sent from RabbitMQ - - Pin pamqp 1.6.1 fixing an issue with max-channels -- 0.26.1 - released *2015-03-09* - - Add the ability to interrupt rabbitpy when waiting on a frame (#38) - - Use a custom base class for all Exceptions (#57) Jeremy Tillman - - Fix for consumer example in documentation (#60) Michael Becker - - Add rabbitpy.amqp module for unopinionated access to AMQP API - - Refactor how client side heartbeat checking is managed when no heartbeat frames have been sent from the server. (#58) - - Address an issue when client side channel max count is not set and server side channel max is set to 65535 (#62) - - Clean up handling of remote channel and connection closing - - Clean up context manager exiting for rabbitpy.Queue - - Remove default prefetch count for simple consuming - - Fix URI query parameter names to match AMQP URI spec on rabbitmq.com - - Fix behavior of SSL flags in query parameters (#63, #64) - - PYPY behavior fixes related to garbage collection -- 0.25.0 - released *2014-12-16* - - Acquire a lock when creating a new channel to fix multi-threaded channel creation behavior (#56) - - Add client side heartbeat checking. If 2 heartbeats are missed, a ConnectionResetException exception will be raised (#55) - - Fix a bug where Basic.Nack checking was checking for the wrong string to test for support - - Add support for Python3 memoryviews for the message body when creating a new rabbitpy.Message (#50) - - Improve Python3 behavior in rabbitpy.utils.maybe_utf8_encode: ensure the object being cast as a bytes object with utf-8 encoding is a string -- 0.24.0 - released *2014-12-12* - - Update to reflect changes in pamqp 1.6.0 - - Update how message property data types are retrieved - - Fix tests relying on .__dict__ -- 0.23.0 - released *2014-11-5* - - Fix a bug where message body length was being assigned to the content header prior to converting the unicode string to bytes (#49) - - Add a new rabbitpy.utils.maybe_utf8_encode method for handling strings that may or may not contain unicode (#49) - - Fix the automatic coercion of header types to UTF-8 encoded bytes (#49) - - Fix an integration test that was not cleaning up its queue after itself - - Raise TypeError if a timestamp property can not be converted properly -- 0.22.0 - released *2014-11-4* - - Address an issue when RabbitMQ is configured with a max-frame-size of 0 (#48) - - Do not lose the traceback when exiting a context manager due to a an exception (#46) - - Adds server capability checking in rabbitpy.Channel methods that require RabbitMQ enhancements to the AMQP protocol (Publisher confirms, consumer priorities, & Baisc.Nack). If unsupported functionality is used, a rabbitpy.exceptions.NotSupportedError exception will be raised. - - Pin pamqp version range to >= 1.4, < 2.0 - - Fix wheel distribution -- 0.21.1 - released *2014-10-23* - - Clean up KQueue issues found when troubleshooting #44, checking for socket EOF in flags to detect connection reset - - Remove sockets from KQueue when in error state - - Change behavior when there is a poll exception list - - Handle socket connect errors more cleanly (#44) - - Handle bug for how we pull the error string from an exception in IO.on_error (#44) - - Re-raise exceptions causing the exit of Connection or Channel so they can be cleanly caught (#44) -- 0.21.0 - released *2014-10-21* - - Address a possible edge case where message frames can be interspersed when publishing in a multi-threaded environment - - Add exception handling around select.error (#43) - - Check all frames for Channel.CloseOk when consuming - - Add a new ``opinionated`` flag in rabbitpy.Message construction that deprecates the ``auto_id`` flag - - Add wheel distribution -- 0.20.0 - released *2014-10-01* - - Added support for KQueue and Poll in IOLoop for performance improvements - - Fixed issues with publishing large messages and socket resource availability errors (#37) - - Add exchange property to rabbitpy.Message (#40) - - Fix exception when timestamp is None in received Message (#41) - - Fix rabbitpy.Message.json() in Python 3.4 (#42) - - Add out-of-band consumer cancellation with Queue.stop_consuming() (#38, #39) - - Add new simple method rabbitpy.create_headers_exchange() - - Significantly increase test coverage -- 0.19.0 - released *2014-06-30* - - Fix the socket read/write buffer size (#35) - - Add new flag in channels to use blocking queue.get operations increasing throughput and lowering overhead. -- 0.18.1 - released *2014-05-15* - - Fix unicode message body encoding in Python 2 -- 0.18.0 - released *2014-05-15* - - Make IO thread daemonic - - block on RPC reads for 1 second instead of 100ms - - add the Message.redelivered property -- 0.17.0 - released *2014-04-16* - - Refactor cross-thread communication for RabbitMQ invoked RPC methods - - fix unclean shutdown conditions and cross-thread exceptions -- 0.16.0 - released *2014-04-10* - - Fix an issue with no_ack=True consumer cancellation - - Fix exchange and queue unbinding - - Add wait on the SOCKET_OPENED event when connecting - - Deal with str message body values in Python 3 by casting to bytes and encoding as UTF-8. -- 0.15.1 - released *2014-01-27* - - Fix an issue with Python 3 IO write trigger -- 0.15.0 - released *2014-01-27* - - Change default durability for Exchange and Queue to False - - Fix a SSL connection issue -- 0.14.2 - released *2014-01-23* - - Fix an issue when IPv6 is the default protocol for the box rabbitpy is being used on -- 0.14.1 - released *2014-01-23* - - Assign queue name for RabbitMQ named queues in rabbitpy.Queue.declare -- 0.14.0 - released *2014-01-22* - - Add support for authentication_failure_close - - Add consumer priorities - - Exception cleanup - - Queue consuming via Queue.__iter__ - - Queue & Exchange attributes are no longer private - - Tx objects can be used as a context manager - - Experimental support for Windows. -- 0.13.0 - released *2014-01-17* - - Validate heartbeat is always an integer - - add arguments to Queue for expires, message-ttl, max-length, & dead-lettering -- 0.12.3 - released *2013-12-23* - - Minor Message.pprint() reformatting -- 0.12.2 - released *2013-12-23* - - Add Exchange and Routing Key to Message.pprint, check for empty method frames in Channel._create_message -- 0.12.1 - released *2013-12-19* - - Fix exception with pika.exceptions.AMQP -- 0.12.0 - released *2013-12-19* - - Updated simple consumer to potential one-liner - - Added rabbitpy.Message.pprint() -- 0.11.0 - released *2013-12-19* - - Major bugfix focused on receiving multiple AMQP frames at the same time. - - Add auto-coercion of property data-types. -- 0.10.0 - released *2013-12-11* - - Rewrite of IO layer yielding improved performance and reduction of CPU usage, bugfixes -- 0.9.0 - released *2013-10-02* - - Major performance improvements, CPU usage reduction, minor bug-fixes -- 0.8.0 - released *2013-10-01* - - Major bugfixes - - IPv6 support -- 0.7.0 - released *2013-10-01* - - Bugfixes and code cleanup. - - Most notable fix around Basic.Return and recursion in Channel._wait_on_frame. -- 0.6.0 - released *2013-09-30* - - Bugfix with Queue.get() - - Bugfix with RPC requests expecting multiple responses - - Add Queue.consume_messages() method. -- 0.5.1 - released *2013-09-24* - - Installer/setup fix -- 0.5.0 - released *2013-09-23* - - Bugfix release including low level socket sending fix and connection timeouts. -- < 0.5.0 - - Previously called rmqid diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..22eea76 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,43 @@ +# rabbitpy + +rabbitpy is a pure python, thread-safe, and pythonic BSD-licensed AMQP/RabbitMQ +client library. It aims to provide a simple and easy to use API for interfacing +with RabbitMQ, minimizing the programming overhead often found in other libraries. + +## Installation + +```bash +pip install rabbitpy +``` + +## Quick Start + +### Connect and publish a message + +```python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + message = rabbitpy.Message(channel, 'Hello, RabbitMQ!') + message.publish('my-exchange', 'routing-key') +``` + +### Consume messages + +```python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, 'my-queue') + for message in queue: + print(message.body) + message.ack() +``` + +## Links + +- [GitHub](https://github.com/gmr/rabbitpy) +- [PyPI](https://pypi.org/project/rabbitpy/) +- [Issue Tracker](https://github.com/gmr/rabbitpy/issues) diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e0277eb..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,75 +0,0 @@ -.. rabbitpy documentation master file, created by - sphinx-quickstart on Wed Mar 27 18:31:37 2013. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -rabbitpy: RabbitMQ Simplified -============================= -rabbitpy is a pure python, thread-safe [#]_, and pythonic BSD Licensed AMQP/RabbitMQ -library that supports Python 2.7+ and 3.4+. rabbitpy aims to provide a simple -and easy to use API for interfacing with RabbitMQ, minimizing the programming -overhead often found in other libraries. - -|Version| - -Installation ------------- -rabbitpy is available from the `Python Package Index `_ and can be -installed by running :command:`easy_install rabbitpy` or :command:`pip install rabbitpy` - -API Documentation ------------------ -rabbitpy is designed to have as simple and pythonic of an API as possible while -still remaining true to RabbitMQ and to the AMQP 0-9-1 specification. There are -two basic ways to interact with rabbitpy, using the simple wrapper methods: - -.. toctree:: - :glob: - :maxdepth: 2 - - simple - -And by using the core objects: - -.. toctree:: - :glob: - :maxdepth: 1 - - api/* - -.. [#] If you're looking to use rabbitpy in a multi-threaded application, you should the notes about multi-threaded use in :doc:`threads`. - -Examples --------- -.. toctree:: - :maxdepth: 2 - :glob: - - examples/* - -Issues ------- -Please report any issues to the Github repo at `https://github.com/gmr/rabbitpy/issues `_ - -Source ------- -rabbitpy source is available on Github at `https://github.com/gmr/rabbitpy `_ - -Version History ---------------- -See :doc:`history` - -Inspiration ------------ -rabbitpy's simple and more pythonic interface is inspired by `Kenneth Reitz's `_ awesome work on `requests `_. - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - -.. |Version| image:: https://badge.fury.io/py/rabbitpy.svg? - :target: http://badge.fury.io/py/rabbitpy diff --git a/docs/simple.rst b/docs/simple.rst deleted file mode 100644 index 1dac31b..0000000 --- a/docs/simple.rst +++ /dev/null @@ -1,16 +0,0 @@ -Simple API Methods -================== - -rabbitpy's simple API methods are meant for one off use, either in your apps or in -the python interpreter. For example, if your application publishes a single -message as part of its lifetime, :py:meth:`rabbitpy.publish` should be enough -for almost any publishing concern. However if you are publishing more than -one message, it is not an efficient method to use as it connects and disconnects -from RabbitMQ on each invocation. :py:meth:`rabbitpy.get` also connects and -disconnects on each invocation. :py:meth:`rabbitpy.consume` does stay connected -as long as you're iterating through the messages returned by it. Exiting the -generator will close the connection. For a more complete api, see the rabbitpy -core API. - -.. automodule:: rabbitpy.simple - :members: diff --git a/docs/threads.md b/docs/threads.md new file mode 100644 index 0000000..60d5b31 --- /dev/null +++ b/docs/threads.md @@ -0,0 +1,68 @@ +# Multi-threaded Use + +To ensure that the network communication module at the core of rabbitpy is +thread-safe, the `rabbitpy.io.IO` class is a daemonic Python thread that uses +a combination of `threading.Event`, `queue.Queue`, and a local cross-platform +implementation of a read-write socket pair. + +While ensuring that the core socket I/O and dispatching of AMQP frames across +threads is safe, it does not protect against cross-thread channel utilization. + +Due to the way channel events are managed, **restrict each channel to a single +thread**. Sharing channels across threads can create issues with channel state +in the AMQP protocol. + +## Example + +The following example uses the main thread to connect to RabbitMQ and then +spawns separate threads for publishing and consuming: + +```python +import rabbitpy +import threading + +EXCHANGE = 'threading_example' +QUEUE = 'threading_queue' +ROUTING_KEY = 'test' +MESSAGE_COUNT = 100 + + +def consumer(connection): + """Consume MESSAGE_COUNT messages then exit.""" + received = 0 + with connection.channel() as channel: + for message in rabbitpy.Queue(channel, QUEUE): + print(message.body) + message.ack() + received += 1 + if received == MESSAGE_COUNT: + break + + +def publisher(connection): + """Publish MESSAGE_COUNT messages.""" + with connection.channel() as channel: + for index in range(MESSAGE_COUNT): + message = rabbitpy.Message(channel, f'Message #{index}') + message.publish(EXCHANGE, ROUTING_KEY) + + +with rabbitpy.Connection() as connection: + with connection.channel() as channel: + exchange = rabbitpy.Exchange(channel, EXCHANGE) + exchange.declare() + + queue = rabbitpy.Queue(channel, QUEUE) + queue.declare() + queue.bind(EXCHANGE, ROUTING_KEY) + + kwargs = {'connection': connection} + + consumer_thread = threading.Thread(target=consumer, kwargs=kwargs) + consumer_thread.start() + + publisher_thread = threading.Thread(target=publisher, kwargs=kwargs) + publisher_thread.start() + + consumer_thread.join() +``` diff --git a/docs/threads.rst b/docs/threads.rst deleted file mode 100644 index cc387d0..0000000 --- a/docs/threads.rst +++ /dev/null @@ -1,94 +0,0 @@ -Multi-threaded Use Notes -======================== -To ensure that the network communication module at the core of rabbitpy is -thread safe, the :py:class:`rabbitpy.io.IO` class is a daemonic Python thread -that uses a combination of :py:class:`threading.Event`, :py:class:`Queue.Queue`, -and a local cross-platform implementation of a read-write socket pair in -:py:attr:`rabbitpy.IO.write_trigger`. - -While ensuring that the core socket IO and dispatching of AMQP frames across -threads goes a long way to make sure that multi-threaded applications can safely -use rabbitpy, it does not protect against cross-thread channel utilization. - -Due to the way that channels events are managed, it is recommend that you restrict -the use of a channel to an individual thread. By not sharing channels across -threads, you will ensure that you do not accidentally create issues with -channel state in the AMQP protocol. As an asynchronous RPC style protocol, when -you issue commands, such as a queue declaration, or are publishing a message, -there are expectations in the conversation on a channel about the order of -events and frames sent and received. - -The following example uses the main Python thread to connect to RabbitMQ and -then spawns a thread for publishing and a thread for consuming. - -.. code :: python - - import rabbitpy - import threading - - EXCHANGE = 'threading_example' - QUEUE = 'threading_queue' - ROUTING_KEY = 'test' - MESSAGE_COUNT = 100 - - - def consumer(connection): - """Consume MESSAGE_COUNT messages on the connection and then exit. - - :param rabbitpy.Connection connection: The connection to consume on - - """ - received = 0 - with connection.channel() as channel: - for message in rabbitpy.Queue(channel, QUEUE).consume_messages(): - print message.body - message.ack() - received += 1 - if received == MESSAGE_COUNT: - break - - - def publisher(connection): - """Pubilsh up to MESSAGE_COUNT messages on connection - on an individual thread. - - :param rabbitpy.Connection connection: The connection to publish on - - """ - with connection.channel() as channel: - for index in range(0, MESSAGE_COUNT): - message = rabbitpy.Message(channel, 'Message #%i' % index) - message.publish(EXCHANGE, ROUTING_KEY) - - - # Connect to RabbitMQ - with rabbitpy.Connection() as connection: - - # Open the channel, declare and bind the exchange and queue - with connection.channel() as channel: - - # Declare the exchange - exchange = rabbitpy.Exchange(channel, EXCHANGE) - exchange.declare() - - # Declare the queue - queue = rabbitpy.Queue(channel, QUEUE) - queue.declare() - - # Bind the queue to the exchange - queue.bind(EXCHANGE, ROUTING_KEY) - - - # Pass in the kwargs - kwargs = {'connection': connection} - - # Start the consumer thread - consumer_thread = threading.Thread(target=consumer, kwargs=kwargs) - consumer_thread.start() - - # Start the pubisher thread - publisher_thread = threading.Thread(target=publisher, kwargs=kwargs) - publisher_thread.start() - - # Join the consumer thread, waiting for it to consume all MESSAGE_COUNT messages - consumer_thread.join() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..329483a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,56 @@ +site_name: rabbitpy +site_description: A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library +site_url: https://gmr.github.io/rabbitpy/ +repo_url: https://github.com/gmr/rabbitpy +repo_name: gmr/rabbitpy + +theme: + name: material + palette: + - scheme: default + primary: deep orange + accent: orange + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: deep orange + accent: orange + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - content.code.copy + - navigation.footer + - navigation.sections + - navigation.top + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + show_source: false + show_root_heading: true + heading_level: 2 + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.snippets + +nav: + - Home: index.md + - API Reference: + - Connection: api/connection.md + - Exceptions: api/exceptions.md + - Examples: + - Consumer: examples/consumer.md + - Publisher: examples/publisher.md + - Message Getter: examples/getter.md + - Threading Notes: threads.md + - Version History: history.md diff --git a/pyproject.toml b/pyproject.toml index f17d229..1844a93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,11 @@ dev = [ "pre-commit", "ruff", ] +docs = [ + "mkdocs>=1.5,<2", + "mkdocs-material>=9.5,<10", + "mkdocstrings[python]", +] [tool.coverage.run] branch = true diff --git a/uv.lock b/uv.lock index 6a12700..7980ae0 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,29 @@ version = 1 revision = 1 requires-python = ">=3.11" +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, +] + +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075 }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874 }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787 }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747 }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602 }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077 }, +] + [[package]] name = "build" version = "1.4.2" @@ -16,6 +39,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/57/3b7d4dd193ade4641c865bc2b93aeeb71162e81fc348b8dad020215601ed/build-1.4.2-py3-none-any.whl", hash = "sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88", size = 24643 }, ] +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -25,6 +57,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582 }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240 }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363 }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994 }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697 }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673 }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120 }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911 }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516 }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795 }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920 }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951 }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703 }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857 }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751 }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154 }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191 }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674 }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259 }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276 }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161 }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452 }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272 }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622 }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056 }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751 }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563 }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265 }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229 }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277 }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817 }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823 }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527 }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563 }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587 }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724 }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956 }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923 }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366 }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752 }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296 }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956 }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652 }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940 }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101 }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109 }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458 }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277 }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758 }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299 }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811 }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706 }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706 }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497 }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511 }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133 }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035 }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321 }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973 }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610 }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962 }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595 }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828 }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138 }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679 }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475 }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230 }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045 }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658 }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769 }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328 }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302 }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127 }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840 }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890 }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379 }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043 }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523 }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -156,6 +289,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357 }, +] + [[package]] name = "identify" version = "2.6.18" @@ -165,6 +319,238 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394 }, ] +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779 }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -183,6 +569,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + [[package]] name = "pamqp" version = "3.3.0" @@ -192,6 +587,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848 }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -217,6 +621,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901 }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -226,6 +652,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-discovery" version = "1.2.1" @@ -294,6 +732,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, +] + [[package]] name = "rabbitpy" version = "3.0.0" @@ -309,6 +759,11 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] [package.metadata] requires-dist = [{ name = "pamqp", specifier = ">=3.0,<4.0" }] @@ -320,6 +775,26 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.5,<2" }, + { name = "mkdocs-material", specifier = ">=9.5,<10" }, + { name = "mkdocstrings", extras = ["python"] }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, +] [[package]] name = "ruff" @@ -346,6 +821,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -400,6 +884,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +] + [[package]] name = "virtualenv" version = "21.2.0" @@ -414,3 +907,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703 wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084 }, ] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +] From 7e1879818857403a2c47e1f920b8f40a409bd438 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 21:02:18 -0400 Subject: [PATCH 40/59] Rename bootstrap --- bootstrap | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100755 bootstrap diff --git a/bootstrap b/bootstrap new file mode 100755 index 0000000..169beb7 --- /dev/null +++ b/bootstrap @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +# +# NAME +# bootstrap -- initialize/update docker environment + +# vim: set ts=2 sts=2 sw=2 et: +set -e + +TEST_HOST=${TEST_HOST:-"127.0.0.1"} + +echo "Integration test host: ${TEST_HOST}" + +get_exposed_port() { + docker compose port $1 $2 | cut -d: -f2 +} + +# Activate the virtual environment +if test -e env/bin/activate +then + . env/bin/activate +fi + +mkdir -p build + +# Stop any running instances and clean up after them, then pull images +docker compose down --volumes --remove-orphans +docker compose pull -q +docker compose up -d --wait + +wait_for rabbitmq + +docker compose exec -T rabbitmq rabbitmqctl await_startup + +cat > build/test.env < Date: Tue, 31 Mar 2026 21:09:09 -0400 Subject: [PATCH 41/59] Fix connection lifecycle and IO thread shutdown (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the connection context manager and IO thread: - __exit__ set state to CLOSED but never stopped the IO thread when an exception occurred, leaving the daemonic thread running and the socket open. Now calls _stop_io() and returns False to re-raise. - _exceptions.get() in __exit__ blocks indefinitely; changed to get_nowait() which raises queue.Empty immediately if nothing is pending. - connection.close() put None into _write_queue which nothing reads; the IO thread only exits when its internal asyncio Event is set. Now calls _stop_io() → io.stop() which uses call_soon_threadsafe to signal the running event loop thread-safely. Also fixed two bugs in IO._on_read_ready: - channel_id is None was checked before remaining == self._buffer, causing a RuntimeError on the normal "incomplete frame, wait for more data" path. - recv() returning empty bytes (clean remote close) was unhandled; now adds ConnectionResetException and signals shutdown. - TimeoutError from recv() is transient (SSL can mark a socket readable before a complete record is available); now returns early rather than treating it as a fatal error. _close_socket no longer touches the event loop; reader/writer removal is handled inside _run() while still within the asyncio loop, making it thread-safe. Co-Authored-By: Gavin M. Roy --- rabbitpy/connection.py | 15 ++++++++-- rabbitpy/io.py | 66 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index c44ba40..833d5ca 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -109,13 +109,16 @@ def __exit__( ): """Close the connection when the context exits""" if exc_type and exc_val: + self._stop_io() self._set_state(self.CLOSED) - raise exc_val + return False # Re-raise the original exception try: - exc = self._exceptions.get() + exc = self._exceptions.get_nowait() except queue.Empty: pass else: + self._stop_io() + self._set_state(self.CLOSED) raise exc self.close() @@ -142,7 +145,13 @@ def close(self) -> None: self._events.clear(events.SOCKET_CLOSE) self._events.clear(events.SOCKET_CLOSED) self._events.clear(events.SOCKET_OPENED) - self._write_queue.put_nowait(None) + self._stop_io() + self._set_state(self.CLOSED) + + def _stop_io(self) -> None: + """Signal the IO thread to stop if it is running.""" + if self._io is not None: + self._io.stop() def connect(self) -> None: """Connect to the RabbitMQ Server""" diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 2112ba8..008d777 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -86,13 +86,31 @@ def add_channel(self, channel: int, read_queue: queue.Queue) -> None: self._channels[int(channel)] = read_queue def close(self) -> None: - """Close the socket and update event states.""" + """Close the socket and update event states. + + Called from within the IO thread's finally block after the event loop + exits. Do not call this from outside — use stop() instead. + + """ with self._lock: self._events.clear(rabbitpy.events.SOCKET_OPENED) self._close_socket() self._closed.set() self._events.set(rabbitpy.events.SOCKET_CLOSED) + def stop(self) -> None: + """Signal the I/O loop to stop from outside the IO thread. + + Thread-safe: schedules the close event via the running event loop, or + sets it directly if the loop is not yet running. + + """ + loop = self._ioloop + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(self._closed.set) + else: + self._closed.set() + def run(self) -> None: """Run the I/O loop.""" try: @@ -150,13 +168,13 @@ def _add_exception(self, exc: Exception) -> None: self._events.set(rabbitpy.events.EXCEPTION_RAISED) def _close_socket(self) -> None: - """Close the socket and remove it from the I/O loop.""" - if self._socket is not None and self._ioloop is not None: - try: - self._ioloop.remove_reader(self._socket.fileno()) - self._ioloop.remove_writer(self._socket.fileno()) - except (AttributeError, IndexError, OSError): - pass + """Close the underlying socket. + + Must be called with self._lock held. Reader/writer removal from the + event loop is handled inside _run() while the loop is still active. + + """ + if self._socket is not None: try: self._socket.shutdown(socket.SHUT_RDWR) self._socket.close() @@ -275,15 +293,30 @@ def _on_error(self, exception: OSError) -> None: def _on_read_ready(self) -> None: """Read data from the socket and process any complete frames.""" with self._lock: - self._buffer += self._socket.recv(32768) + if self._socket is None: + return + try: + data = self._socket.recv(32768) + except TimeoutError: + return # Transient; wait for the next readable event + except OSError as error: + self._on_error(error) + self._closed.set() + return + if not data: + # Remote end closed the connection cleanly + self._add_exception( + rabbitpy.exceptions.ConnectionResetException() + ) + self._closed.set() + return + self._buffer += data while self._buffer: remaining, channel_id, frame_value, bytes_read = ( self._on_data_received(self._buffer) ) - if channel_id is None: - raise RuntimeError('Invalid channel ID received') if remaining == self._buffer: - break + break # Incomplete frame — wait for more data self._buffer = remaining self._bytes_read += bytes_read self._channels[channel_id].put(frame_value) @@ -332,3 +365,12 @@ async def _run(self): ) await self._closed.wait() + + # Remove readers/writers from inside the event loop (thread-safe) + with self._lock: + if self._socket is not None: + try: + self._ioloop.remove_reader(self._socket.fileno()) + self._ioloop.remove_writer(self._socket.fileno()) + except (AttributeError, OSError): + pass From ebd0cb6e993510b18cb9235583eadfe1c8cef902 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 21:12:46 -0400 Subject: [PATCH 42/59] Switch integration test env to dotenv - bootstrap writes .env in KEY=VALUE dotenv format instead of build/test.env with export prefixes; removes redundant wait_for and rabbitmqctl await_startup calls (docker compose up --wait handles it) - Add python-dotenv to dev dependency-group - tests/mixins.py uses dotenv.load_dotenv() instead of manual parsing - tests/integration/test_connection.py reads RABBITMQ_URL from os.environ and skips with @skipUnless when the var is not set (no RabbitMQ running) - CI testing.yaml writes .env directly instead of build/test.env - Add .env to .gitignore Co-Authored-By: Gavin M. Roy --- .github/workflows/testing.yaml | 9 +++------ .gitignore | 1 + bootstrap | 18 +++--------------- pyproject.toml | 1 + tests/integration/test_connection.py | 23 +++++++++-------------- tests/mixins.py | 16 +++++----------- uv.lock | 11 +++++++++++ 7 files changed, 33 insertions(+), 46 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index c2a0104..7e343bf 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -42,13 +42,10 @@ jobs: - name: Install dependencies run: uv sync --all-groups - - name: Create build directory and test env + - name: Write .env for integration tests run: | - mkdir -p build - cat > build/test.env <> .env + echo "MANAGEMENT_URL=http://localhost:15672/%2f" >> .env - name: Lint Check run: uv run pre-commit run --all-files diff --git a/.gitignore b/.gitignore index 10171f8..6ed6101 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ dist/ *.egg-info/ tests/data/ca.key +.env diff --git a/bootstrap b/bootstrap index 169beb7..25e4a11 100755 --- a/bootstrap +++ b/bootstrap @@ -14,24 +14,12 @@ get_exposed_port() { docker compose port $1 $2 | cut -d: -f2 } -# Activate the virtual environment -if test -e env/bin/activate -then - . env/bin/activate -fi - -mkdir -p build - # Stop any running instances and clean up after them, then pull images docker compose down --volumes --remove-orphans docker compose pull -q docker compose up -d --wait -wait_for rabbitmq - -docker compose exec -T rabbitmq rabbitmqctl await_startup - -cat > build/test.env < .env < Date: Tue, 31 Mar 2026 21:14:36 -0400 Subject: [PATCH 43/59] Remove bootstrap.sh (renamed to bootstrap) Co-Authored-By: Gavin M. Roy --- bootstrap.sh | 61 ---------------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100755 bootstrap.sh diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100755 index b0739b0..0000000 --- a/bootstrap.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env sh -# -# NAME -# bootstrap -- initialize/update docker environment - -# vim: set ts=2 sts=2 sw=2 et: -set -e - -TEST_HOST=${TEST_HOST:-"127.0.0.1"} - -echo "Integration test host: ${TEST_HOST}" - -get_exposed_port() { - docker compose port $1 $2 | cut -d: -f2 -} - -wait_for() { - printf 'Waiting for %s... ' $1 - counter="0" - while true - do - if [ "$( docker compose ps | grep $1 | grep -c healthy )" -eq 1 ]; then - break - fi - counter=$((counter+1)) - if [ "${counter}" -eq 120 ]; then - echo " ERROR: container failed to start" - exit 1 - fi - sleep 1 - done - echo 'done.' -} - -# Ensure Docker is Running -echo "Docker Information:" -echo "" -docker version -echo "" - -# Activate the virtual environment -if test -e env/bin/activate -then - . env/bin/activate -fi - -mkdir -p build - -# Stop any running instances and clean up after them, then pull images -docker compose down --volumes --remove-orphans -docker compose pull -q -docker compose up -d - -wait_for rabbitmq - -docker compose exec -T rabbitmq rabbitmqctl await_startup - -cat > build/test.env < Date: Tue, 31 Mar 2026 21:21:35 -0400 Subject: [PATCH 44/59] Fix SSLMockServerTestCase race on Python 3.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused test_on_data_received to fail on Python 3.12 where the SSL handshake takes ~800ms vs <200ms on other versions: - asyncSetUp used asyncio.sleep(0.1) to wait for the connection, which was too short. Replaced with a polling loop that yields to the event loop between checks. A blocking events.wait() can't be used because the SSL mock server runs inside the test's event loop — blocking it would deadlock the SSL handshake. - test_on_data_received checked self.io._buffer before getting the frame from the queue, creating a race: the IO thread may not have processed the server's response yet. Reordered so the frame is retrieved first (blocks up to 3s), then the buffer is checked. Co-Authored-By: Gavin M. Roy --- tests/test_io.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_io.py b/tests/test_io.py index 3ebc983..9f75328 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -442,7 +442,17 @@ class SSLMockServerTestCase(MockServerTestCase): async def asyncSetUp(self): await super().asyncSetUp() self.io.start() - await asyncio.sleep(0.1) + # Yield to the event loop so the in-process SSL mock server can + # complete its handshake (blocking wait() would deadlock the loop). + for _ in range(50): + if self.events.is_set(rabbitpy.events.SOCKET_OPENED): + break + await asyncio.sleep(0.1) + self.assertTrue( + self.events.is_set(rabbitpy.events.SOCKET_OPENED), + 'Timeout waiting for SOCKET_OPENED event', + ) + self.assertTrue(self.io.is_connected) @staticmethod async def _create_server(): @@ -474,10 +484,12 @@ async def test_on_data_received(self): await self.write_protocol_header() - self.assertEqual(self.io._buffer, b'\x01\x00\x00') - self.assertEqual(self.io.bytes_received, 12) + # Get the frame first — blocks until the IO thread has processed the + # full response, avoiding a race on _buffer and bytes_received. 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) class Error35(OSError): From dbeafc40fc0d62be3530b37d19bf71519b4a2076 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 21:49:26 -0400 Subject: [PATCH 45/59] Make IO shutdown synchronous and stop-during-startup safe Address two CodeRabbit issues raised on PR #147: - connection.py: _stop_io() now joins the IO thread after calling stop(), blocking until the thread has exited (up to the configured timeout). This ensures callers of __exit__ and close() can rely on the socket being fully torn down before the state transitions to CLOSED, eliminating the nondeterminism CodeRabbit flagged. - io.py: Introduce a dedicated _stop_requested threading.Event flag. stop() sets this flag in addition to signalling _closed, so a stop() call that arrives while _connect() is still running is not silently discarded when _connect() clears _closed unconditionally (line 208). _connect() now checks _stop_requested before clearing _closed and aborts gracefully if set, preventing the IO thread from entering the read/write loop after an external shutdown request. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rabbitpy/connection.py | 20 +++++++++++++++++--- rabbitpy/io.py | 10 +++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index 833d5ca..188a568 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -149,9 +149,23 @@ def close(self) -> None: self._set_state(self.CLOSED) def _stop_io(self) -> None: - """Signal the IO thread to stop if it is running.""" - if self._io is not None: - self._io.stop() + """Signal the IO thread to stop and wait for it to finish. + + Blocks until the IO thread has exited (or until the connection timeout + elapses) so that callers can rely on the socket being fully torn down + before they transition to CLOSED. The join is skipped when called from + inside the IO thread itself to prevent a deadlock. + + """ + io_thread = self._io + if io_thread is None: + return + io_thread.stop() + if ( + io_thread.is_alive() + and io_thread is not threading.current_thread() + ): + io_thread.join(self._args['timeout']) def connect(self) -> None: """Connect to the RabbitMQ Server""" diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 008d777..f3a00e8 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -69,6 +69,7 @@ def __init__( self._buffer = b'' self._closed = asyncio.Event() self._closed.set() + self._stop_requested = threading.Event() self._lock = threading.Lock() self._bytes_read = 0 self._bytes_written = 0 @@ -102,9 +103,13 @@ def stop(self) -> None: """Signal the I/O loop to stop from outside the IO thread. Thread-safe: schedules the close event via the running event loop, or - sets it directly if the loop is not yet running. + sets it directly if the loop is not yet running. Also sets + _stop_requested so that a stop() call received during startup (before + _connect() runs) is not silently discarded when _connect() clears + _closed. """ + self._stop_requested.set() loop = self._ioloop if loop is not None and loop.is_running(): loop.call_soon_threadsafe(self._closed.set) @@ -205,6 +210,9 @@ async def _connect(self) -> None: ) ) + if self._stop_requested.is_set(): + sock.close() + return # A stop() arrived during startup; honour it self._closed.clear() self._socket = sock self._socket.setblocking(False) From 61a53258a110b534d7eb86815fd1b543057cb6c7 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 21:58:59 -0400 Subject: [PATCH 46/59] Update pamqp dependency to >=4.0,<5.0 Co-Authored-By: Gavin M. Roy --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fd90c70..ccd84de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ "Typing :: Typed", ] keywords = ["rabbitmq", "amqp", "messaging"] -dependencies = ["pamqp>=3.0,<4.0"] +dependencies = ["pamqp>=4.0,<5.0"] [project.urls] Homepage = "https://github.com/gmr/rabbitpy" diff --git a/uv.lock b/uv.lock index 32c9c2a..61a9253 100644 --- a/uv.lock +++ b/uv.lock @@ -580,11 +580,11 @@ wheels = [ [[package]] name = "pamqp" -version = "3.3.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/92/3db569ab046955553d6041ea5ad1fa78aaa01fc70bc4e6e7e15c072f050e/pamqp-4.0.0.tar.gz", hash = "sha256:0acc96ae642a534c61f12942ee23167ed52624209baa92c9848d0970f4604682", size = 130078 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848 }, + { url = "https://files.pythonhosted.org/packages/8a/23/64f267632476a5c512df38e6f0d6eb215ebc4ff59edd17d152b7e3bee1ee/pamqp-4.0.0-py3-none-any.whl", hash = "sha256:ec5757355b4a54f4cf9c172e8dde89ed14f420970bc64a0c9019920c4945c71b", size = 32443 }, ] [[package]] @@ -776,7 +776,7 @@ docs = [ ] [package.metadata] -requires-dist = [{ name = "pamqp", specifier = ">=3.0,<4.0" }] +requires-dist = [{ name = "pamqp", specifier = ">=4.0,<5.0" }] [package.metadata.requires-dev] dev = [ From 6a11518bcc7def973ab2e9e160406f71cb9d034a Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 22:21:59 -0400 Subject: [PATCH 47/59] Add py.typed marker, mypy/basedpyright, and full type annotations Typing infrastructure: - Add rabbitpy/py.typed marker (PEP 561) - Add mypy>=1.20 and basedpyright>=1.38 to dev dependency-group - Add [tool.mypy] strict config with test overrides (disallow_untyped_defs=false, disallow_untyped_calls=false for tests) - Add [tool.basedpyright] standard mode config - Add mypy and basedpyright local pre-commit hooks via uv run --frozen Type annotation work across the library: - url_parser: add SslOptions and ConnectionArgs TypedDicts; parse() returns ConnectionArgs; fix str/bytes ambiguity from urlparse; fix _query_args_multi_key_value return type (str | None not int|float|str) - io: use pamqp.frame.FrameTypes from pamqp 4 as canonical frame union; type all Queue/deque/defaultdict generics; add SslOptions to __init__; add -> None to _run; use local loop variable to avoid ioloop None checks; fix _on_write_ready socket None guard; fix certfile/verify SSL logic - connection: type all Queue generics; use typing.Self for __enter__; -> bool | None on __exit__; args property returns ConnectionArgs; blocked uses bool() to avoid bool | None return - channel0: type pending_frames as Queue[FrameTypes | None] - events: add -> None to __init__ - exceptions: add -> str to all __str__ methods; str(self.args[0]) for ActionException to avoid returning Any - state: -> None on __init__ and _set_state Test fixes: - Use SslOptions TypedDict in all ssl_options args and assignments - Add assert isinstance(...) after assertIsInstance where type checkers need narrowing (frame.locales, context.check_hostname) - Fix bytes/str comparison in MockServer.data_received - Type annotate MockServer protocol methods; BaseTransport for _transport - Add -> None to set_expectation/set_response; type write_frame/header Co-Authored-By: Gavin M. Roy --- .pre-commit-config.yaml | 17 ++++ pyproject.toml | 22 ++++- rabbitpy/channel0.py | 8 +- rabbitpy/connection.py | 29 ++++--- rabbitpy/events.py | 2 +- rabbitpy/exceptions.py | 32 ++++---- rabbitpy/io.py | 92 ++++++++++----------- rabbitpy/py.typed | 0 rabbitpy/state.py | 2 +- rabbitpy/url_parser.py | 133 ++++++++++++++++++++---------- tests/test_io.py | 89 ++++++++++++++------- uv.lock | 173 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 449 insertions(+), 150 deletions(-) create mode 100644 rabbitpy/py.typed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1709e52..e2071ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,3 +17,20 @@ repos: - id: ruff-format - id: ruff args: [--fix] + + - repo: local + hooks: + - id: mypy + name: mypy + entry: uv run --frozen mypy + language: system + types: [python] + args: [rabbitpy/, tests/] + pass_filenames: false + + - id: basedpyright + name: basedpyright + entry: uv run --frozen basedpyright + language: system + types: [python] + pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index ccd84de..e502203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,10 @@ Documentation = "https://rabbitpy.readthedocs.io" [dependency-groups] dev = [ + "basedpyright", "build", "coverage[toml]", + "mypy", "pre-commit", "python-dotenv", "ruff", @@ -69,7 +71,25 @@ directory = "build/coverage" output = "build/coverage.xml" [tool.hatch.build.targets.wheel] -include = ["rabbitpy"] +include = ["rabbitpy", "rabbitpy/py.typed"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +disallow_untyped_calls = false +check_untyped_defs = true + +[tool.basedpyright] +pythonVersion = "3.11" +typeCheckingMode = "standard" +include = ["rabbitpy", "tests"] +exclude = ["examples/**", ".venv/**"] [tool.ruff] line-length = 79 diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index 821c758..4bb1622 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -1,9 +1,13 @@ import logging import queue +import pamqp.frame + LOGGER = logging.getLogger(__name__) class Channel0: - def __init__(self): - self.pending_frames = queue.Queue() + def __init__(self) -> None: + self.pending_frames: queue.Queue[pamqp.frame.FrameTypes | None] = ( + queue.Queue() + ) diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index 188a568..738a78e 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -16,8 +16,10 @@ class ClientProperties: product = 'rabbitpy' version = __version__ - def __init__(self, additional_properties: dict | None = None): - self._properties: dict[str, str | dict[str, bool]] = { + def __init__( + self, additional_properties: dict[str, typing.Any] | None = None + ) -> None: + self._properties: dict[str, typing.Any] = { 'information': self.information, 'platform': self.platform, 'product': self.product, @@ -33,7 +35,7 @@ def __init__(self, additional_properties: dict | None = None): 'publisher_confirms': True, } - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, typing.Any]: return self._properties @@ -78,22 +80,22 @@ def __init__( self, url: str | None = None, connection_name: str | None = None, - client_properties: dict | None = None, - ): + client_properties: dict[str, typing.Any] | None = None, + ) -> None: """Create a new instance of the Connection object""" super().__init__() - self._args = url_parser.parse(url) + self._args: url_parser.ConnectionArgs = url_parser.parse(url) self._channel_lock = threading.Lock() self._channel0 = channel0.Channel0() self._client_properties = ClientProperties(client_properties) self._events = events.Events() - self._exceptions = queue.Queue() + self._exceptions: queue.Queue[Exception] = queue.Queue() self._io: io.IO | None = None self._max_frame_size: int | None = None self._name = connection_name or '0x%x' % id(self) # noqa: UP031 - self._write_queue = queue.Queue() + self._write_queue: queue.Queue[typing.Any] = queue.Queue() - def __enter__(self) -> 'Connection': + def __enter__(self) -> typing.Self: """For use as a context manager, return a handle to this object instance. @@ -106,7 +108,7 @@ def __exit__( exc_type: type[BaseException] | None, exc_val: BaseException | None, unused_exc_tb: types.TracebackType | None, - ): + ) -> bool | None: """Close the connection when the context exits""" if exc_type and exc_val: self._stop_io() @@ -121,11 +123,12 @@ def __exit__( self._set_state(self.CLOSED) raise exc self.close() + return None @property - def args(self) -> dict: + def args(self) -> url_parser.ConnectionArgs: """Return the connection arguments.""" - return dict(self._args) + return typing.cast(url_parser.ConnectionArgs, dict(self._args)) @property def blocked(self) -> bool: @@ -136,7 +139,7 @@ def blocked(self) -> bool: that was added in RabbitMQ 3.2. """ - return self._events.is_set(events.CONNECTION_BLOCKED) or False + return bool(self._events.is_set(events.CONNECTION_BLOCKED)) def close(self) -> None: """Close the connection to RabbitMQ.""" diff --git a/rabbitpy/events.py b/rabbitpy/events.py index 3df901e..4203c75 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -42,7 +42,7 @@ class Events: """ - def __init__(self): + def __init__(self) -> None: """Create a new instance of Events""" self._events = self._create_event_objects() diff --git a/rabbitpy/exceptions.py b/rabbitpy/exceptions.py index 9e7bece..90425cb 100644 --- a/rabbitpy/exceptions.py +++ b/rabbitpy/exceptions.py @@ -25,14 +25,14 @@ class ActionException(RabbitpyException): """ - def __str__(self): - return self.args[0] + def __str__(self) -> str: + return str(self.args[0]) class ChannelClosedException(RabbitpyException): """Raised when an action is attempted on a channel that is closed.""" - def __str__(self): + def __str__(self) -> str: return ( 'Can not perform RPC requests on a closed channel, you must ' 'create a new channel' @@ -46,7 +46,7 @@ class ConnectionException(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return f'Unable to connect to the remote server {self.args}' @@ -56,7 +56,7 @@ class ConnectionClosed(ConnectionException): """ - def __str__(self): + def __str__(self) -> str: return 'The connection is closed' @@ -67,14 +67,14 @@ class ConnectionResetException(ConnectionException): """ - def __str__(self): + def __str__(self) -> str: return 'Connection was reset at socket level' class RemoteCancellationException(RabbitpyException): """Raised if RabbitMQ cancels an active consumer""" - def __str__(self): + def __str__(self) -> str: return 'Remote server cancelled the active consumer' @@ -84,7 +84,7 @@ class RemoteClosedChannelException(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return ( f'Channel {self.args[0]} was closed by the remote server ' f'({self.args[1]}): {self.args[2]}' @@ -97,7 +97,7 @@ class RemoteClosedException(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return ( f'Connection was closed by the remote server ' f'({self.args[0]}): {self.args[1]}' @@ -110,7 +110,7 @@ class MessageReturnedException(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return ( f'Message was returned by RabbitMQ: ({self.args[0]}) ' f'for exchange {self.args[1]}' @@ -123,7 +123,7 @@ class NoActiveTransactionError(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return 'No active transaction for the request, channel closed' @@ -133,7 +133,7 @@ class NotConsumingError(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return 'No active consumer to cancel' @@ -143,14 +143,14 @@ class NotSupportedError(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return f'The selected feature "{self.args}" is not supported' class ReceivedOnClosedChannelException(RabbitpyException): """Raised when RabbitMQ sends an RPC on a channel that is closed.""" - def __str__(self): + def __str__(self) -> str: return f'RabbitMQ sent a frame on a closed channel ({self.args[0]})' @@ -163,7 +163,7 @@ class TooManyChannelsError(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return 'The maximum amount of negotiated channels has been reached' @@ -173,7 +173,7 @@ class UnexpectedResponseError(RabbitpyException): """ - def __str__(self): + def __str__(self) -> str: return ( f'Received an expected response, expected {self.args[0]}, ' f'received {self.args[1]}' diff --git a/rabbitpy/io.py b/rabbitpy/io.py index f3a00e8..7424f36 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -16,31 +16,17 @@ import rabbitpy.events import rabbitpy.exceptions +from rabbitpy.url_parser import SslOptions LOGGER = logging.getLogger(__name__) -PamqpFrame = ( - pamqp.base.Frame - | pamqp.body.ContentBody - | pamqp.header.ContentHeader - | pamqp.header.ProtocolHeader - | pamqp.heartbeat.Heartbeat - | None -) - -AddrInfo = ( - list[typing.Any] - | list[ - tuple[ - socket.AddressFamily, - socket.SocketKind, - int, - str, - tuple[str, int], - tuple[str, int, int, int] | None, # IPv6 sockaddr - ] - ] -) +# Convenience alias: pamqp 4 exposes FrameTypes as the canonical union. +# We extend it with None to represent "no frame decoded yet". +PamqpFrame = pamqp.frame.FrameTypes | None + +AddrInfo = list[ + tuple[socket.AddressFamily, socket.SocketKind, int, str, typing.Any] +] class IO(threading.Thread): @@ -51,11 +37,11 @@ def __init__( host: str, port: int, use_ssl: bool, - ssl_options: dict, + ssl_options: SslOptions, events: rabbitpy.events.Events, - exceptions: queue.Queue, + exceptions: queue.Queue[Exception], timeout: float = 0.01, - ): + ) -> None: """Initialize the IO thread.""" super().__init__(daemon=True, name='IO') self._events = events @@ -73,15 +59,17 @@ def __init__( self._lock = threading.Lock() self._bytes_read = 0 self._bytes_written = 0 - self._channels: collections.defaultdict[int, queue.Queue] = ( - collections.defaultdict(queue.Queue) - ) + self._channels: collections.defaultdict[ + int, queue.Queue[PamqpFrame] + ] = collections.defaultdict(queue.Queue) self._ioloop: asyncio.AbstractEventLoop | None = None self._remote_name: str | None = None self._socket: socket.socket | None = None - self._write_buffer = collections.deque() + self._write_buffer: collections.deque[bytes] = collections.deque() - def add_channel(self, channel: int, read_queue: queue.Queue) -> None: + def add_channel( + self, channel: int, read_queue: queue.Queue[PamqpFrame] + ) -> None: """Associate a channel with a queue for frame dispatching.""" with self._lock: self._channels[int(channel)] = read_queue @@ -127,7 +115,9 @@ def run(self) -> None: finally: self.close() - def write_frame(self, channel: int, frame_value: pamqp.base.Frame) -> None: + def write_frame( + self, channel: int, frame_value: pamqp.frame.FrameTypes + ) -> None: """Write an AMQP frame to the socket.""" if not self.is_connected: self._add_exception( @@ -189,26 +179,28 @@ def _close_socket(self) -> None: async def _connect(self) -> None: """Establish a connection to the RabbitMQ server.""" - sock = None + sock: socket.socket | None = None for addr_info in self._get_addr_info(): LOGGER.debug('Attempting to connect to %r', addr_info[4]) try: sock = self._create_socket(*addr_info[0:3]) sock.connect(addr_info[4]) except OSError as error: - sock.close() + if sock is not None: + sock.close() sock = None LOGGER.debug('Error connecting to %r: %s', addr_info[4], error) continue else: break - if not sock: - return self._add_exception( + if sock is None: + self._add_exception( rabbitpy.exceptions.ConnectionException( self._host, self._port, 'Could not connect' ) ) + return if self._stop_requested.is_set(): sock.close() @@ -258,13 +250,14 @@ def _get_ssl_context(self) -> ssl.SSLContext | None: ) else: ssl_context.load_default_certs(ssl.Purpose.SERVER_AUTH) - if self._ssl_options.get('certfile'): + certfile = self._ssl_options.get('certfile') + if certfile: ssl_context.load_cert_chain( - self._ssl_options.get('certfile', ''), - self._ssl_options.get('keyfile'), + certfile, self._ssl_options.get('keyfile') ) - if self._ssl_options.get('verify') is not None: - ssl_context.verify_mode = self._ssl_options['verify'] + verify = self._ssl_options.get('verify') + if verify is not None: + ssl_context.verify_mode = verify return ssl_context return None @@ -274,7 +267,6 @@ def _on_data_received( ) -> tuple[bytes, int | None, PamqpFrame, int]: """Unmarshal a pamqp frame from received socket data. - :param value: The bytes received from the socket. :return: Tuple containing remaining bytes, channel ID, frame, and bytes consumed. @@ -327,7 +319,8 @@ def _on_read_ready(self) -> None: break # Incomplete frame — wait for more data self._buffer = remaining self._bytes_read += bytes_read - self._channels[channel_id].put(frame_value) + if channel_id is not None: + self._channels[channel_id].put(frame_value) def _on_write_ready(self) -> None: """Write pending data from the write buffer to the socket.""" @@ -336,6 +329,8 @@ def _on_write_ready(self) -> None: payload = self._write_buffer.popleft() except IndexError: return + if self._socket is None: + return try: self._socket.sendall(payload) except TimeoutError: @@ -355,17 +350,18 @@ def _on_write_ready(self) -> None: with self._lock: self._bytes_written += len(payload) - async def _run(self): + async def _run(self) -> None: """Manage the I/O loop: connect, read, write, and handle closure.""" - self._ioloop = asyncio.get_running_loop() + loop = asyncio.get_running_loop() + self._ioloop = loop with self._lock: await self._connect() if not self._socket: return # Could not establish connection - self._ioloop.add_reader(self._socket, self._on_read_ready) - self._ioloop.add_writer(self._socket, self._on_write_ready) + loop.add_reader(self._socket, self._on_read_ready) + loop.add_writer(self._socket, self._on_write_ready) local_sock = self._socket.getsockname() peer_sock = self._socket.getpeername() self._remote_name = ( @@ -378,7 +374,7 @@ async def _run(self): with self._lock: if self._socket is not None: try: - self._ioloop.remove_reader(self._socket.fileno()) - self._ioloop.remove_writer(self._socket.fileno()) + loop.remove_reader(self._socket.fileno()) + loop.remove_writer(self._socket.fileno()) except (AttributeError, OSError): pass diff --git a/rabbitpy/py.typed b/rabbitpy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rabbitpy/state.py b/rabbitpy/state.py index aba9e19..a818892 100644 --- a/rabbitpy/state.py +++ b/rabbitpy/state.py @@ -21,7 +21,7 @@ class StatefulBase: 3: 'Opening', } - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: """Create a new instance of the object defaulting to a closed state.""" self._state: int = self.CLOSED super().__init__(*args, **kwargs) diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py index 8f12549..b82e04a 100644 --- a/rabbitpy/url_parser.py +++ b/rabbitpy/url_parser.py @@ -2,6 +2,7 @@ import logging import ssl +import typing import urllib.parse from pamqp import constants @@ -18,14 +19,42 @@ DEFAULT_VHOST = '%2F' GUEST = 'guest' PORTS = {'amqp': 5672, 'amqps': 5671} -SSL_CERT_MAP = { +SSL_CERT_MAP: dict[str, ssl.VerifyMode] = { 'ignore': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, 'required': ssl.CERT_REQUIRED, } -def parse(url: str | None = DEFAULT_URL) -> dict: +class SslOptions(typing.TypedDict): + """SSL connection options parsed from an AMQP URL.""" + + check_hostname: bool + cafile: str | None + capath: str | None + certfile: str | None + keyfile: str | None + verify: ssl.VerifyMode | None + + +class ConnectionArgs(typing.TypedDict): + """Parsed AMQP connection arguments.""" + + host: str + port: int + virtual_host: str + username: str + password: str + timeout: int + heartbeat: int + frame_max: int + channel_max: int + locale: str | None + ssl: bool + ssl_options: SslOptions + + +def parse(url: str | None = DEFAULT_URL) -> ConnectionArgs: """Parse the AMQP URL passed in and return the configuration information in a dictionary of values. @@ -78,7 +107,7 @@ def parse(url: str | None = DEFAULT_URL) -> dict: _validate_uri_scheme(parsed.scheme) # Toggle the SSL flag based upon the URL scheme and if SSL is enabled - use_ssl = True if parsed.scheme == AMQPS and ssl else False + use_ssl = bool(parsed.scheme == AMQPS and ssl) # Ensure that SSL is available if SSL is requested if parsed.scheme == 'amqps' and not ssl: @@ -88,57 +117,74 @@ def parse(url: str | None = DEFAULT_URL) -> dict: scheme_port = PORTS[AMQPS] if parsed.scheme == AMQPS else PORTS[AMQP] # Set the vhost to be after the base slash if it was specified - vhost = DEFAULT_VHOST - if parsed.path: - vhost = parsed.path[1:] or DEFAULT_VHOST + # parsed.path is str when the input URL is str (always our case). + path = ( + parsed.path if isinstance(parsed.path, str) else parsed.path.decode() + ) + vhost: str = DEFAULT_VHOST + if path: + vhost = path[1:] or DEFAULT_VHOST # Parse the query string + raw_query = parsed.query query_args = urllib.parse.parse_qs( - parsed.query.decode('utf-8') - if isinstance(parsed.query, bytes) - else parsed.query + raw_query.decode('utf-8') + if isinstance(raw_query, bytes) + else raw_query + ) + + check_hostname_raw = _query_args_str('ssl_check_hostname', query_args, '') + check_hostname = (check_hostname_raw or '').lower() not in ( + '0', + 'false', + 'no', ) # Return the configuration dictionary to use when connecting - return { - 'host': parsed.hostname, - 'port': parsed.port or scheme_port, - 'virtual_host': urllib.parse.unquote(vhost), - 'username': urllib.parse.unquote(parsed.username or GUEST), - 'password': urllib.parse.unquote(parsed.password or GUEST), - 'timeout': _query_args_int('timeout', query_args, DEFAULT_TIMEOUT), - 'heartbeat': _query_args_int( + hostname = parsed.hostname + host: str = ( + hostname.decode() + if isinstance(hostname, bytes) + else hostname or 'localhost' + ) + return ConnectionArgs( + host=host, + port=parsed.port or scheme_port, + virtual_host=urllib.parse.unquote(vhost), + username=urllib.parse.unquote(parsed.username or GUEST), + password=urllib.parse.unquote(parsed.password or GUEST), + timeout=_query_args_int('timeout', query_args, DEFAULT_TIMEOUT), + heartbeat=_query_args_int( 'heartbeat', query_args, DEFAULT_HEARTBEAT_INTERVAL ), - 'frame_max': _query_args_int( + frame_max=_query_args_int( 'frame_max', query_args, constants.FRAME_MAX_SIZE ), - 'channel_max': _query_args_int( + channel_max=_query_args_int( 'channel_max', query_args, DEFAULT_CHANNEL_MAX ), - 'locale': _query_args_str('locale', query_args), - 'ssl': use_ssl, - 'ssl_options': { - 'check_hostname': _query_args_str( - 'ssl_check_hostname', query_args, '' - ).lower() - not in ('0', 'false', 'no'), - 'cafile': _query_args_multi_key_value( + locale=_query_args_str('locale', query_args), + ssl=use_ssl, + ssl_options=SslOptions( + check_hostname=check_hostname, + cafile=_query_args_multi_key_value( ['cacertfile', 'ssl_cacert', 'cafile'], query_args ), - 'capath': _query_args_str('capath', query_args), - 'certfile': _query_args_multi_key_value( + capath=_query_args_str('capath', query_args), + certfile=_query_args_multi_key_value( ['certfile', 'ssl_cert'], query_args ), - 'keyfile': _query_args_multi_key_value( + keyfile=_query_args_multi_key_value( ['keyfile', 'ssl_key'], query_args ), - 'verify': _query_args_ssl_validation(query_args), - }, - } + verify=_query_args_ssl_validation(query_args), + ), + ) -def _query_args_int(key: str, values: dict, default: int) -> int: +def _query_args_int( + key: str, values: dict[str, list[str]], default: int +) -> int: """Return the query arg value as an integer for the specified key or return the specified default value. @@ -147,11 +193,13 @@ def _query_args_int(key: str, values: dict, default: int) -> int: :param default: The default return value """ - return int(values.get(key, [default])[0]) + return int(values.get(key, [str(default)])[0]) def _query_args_str( - key: str, values: dict, default: str | None = None + key: str, + values: dict[str, list[str]], + default: str | None = None, ) -> str | None: """Return the value from the query arguments for the specified key or the default value. @@ -160,12 +208,13 @@ def _query_args_str( :param values: The query value dict returned by urlparse """ - return values.get(key, [default])[0] + result = values.get(key, [default])[0] + return result def _query_args_multi_key_value( - keys: list[str], values: dict -) -> int | float | str | None: + keys: list[str], values: dict[str, list[str]] +) -> str | None: """Try and find the query string value where the value can be specified with different keys. @@ -180,7 +229,9 @@ def _query_args_multi_key_value( return None -def _query_args_ssl_validation(values: dict) -> int | None: +def _query_args_ssl_validation( + values: dict[str, list[str]], +) -> ssl.VerifyMode | None: """Return the value mapped from the string value in the query string for the AMQP URL specifying which level of server certificate validation is required, if any. @@ -208,4 +259,4 @@ def _validate_uri_scheme(scheme: bytes | str) -> None: """ if scheme not in list(PORTS.keys()): - raise ValueError(f'Unsupported URI scheme: {scheme}') + raise ValueError(f'Unsupported URI scheme: {scheme!r}') diff --git a/tests/test_io.py b/tests/test_io.py index 9f75328..1c38845 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -15,8 +15,18 @@ import rabbitpy.events from rabbitpy import events, exceptions, io, url_parser +from rabbitpy.url_parser import SslOptions from tests import mixins +_DEFAULT_SSL_OPTIONS = SslOptions( + check_hostname=True, + cafile=None, + capath=None, + certfile=None, + keyfile=None, + verify=None, +) + LOGGER = logging.getLogger(__name__) DATA_PATH = pathlib.Path(__file__).parent.resolve() / 'data' @@ -26,9 +36,9 @@ class TestCase(unittest.TestCase): def setUp(self): """Set up a common IO instance for tests.""" self.events = events.Events() - self.exceptions = queue.Queue() - self.read_queue = queue.Queue() - self.write_queue = queue.Queue() + self.exceptions: queue.Queue[Exception] = queue.Queue() + self.read_queue: queue.Queue[io.PamqpFrame] = queue.Queue() + self.write_queue: queue.Queue[io.PamqpFrame] = queue.Queue() def assertExceptionAdded(self, expectation): error = self.exceptions.get(False) @@ -50,7 +60,12 @@ def setUp(self): """Set up a common IO instance for tests.""" super().setUp() self.io = io.IO( - 'localhost', 5672, False, {}, self.events, self.exceptions + 'localhost', + 5672, + False, + _DEFAULT_SSL_OPTIONS, + self.events, + self.exceptions, ) self.io.add_channel(0, self.read_queue) @@ -83,6 +98,7 @@ def test_on_data_received(self): self.assertEqual(remaining, b'') self.assertEqual(channel, 0) self.assertIsInstance(frame, commands.Connection.Start) + assert isinstance(frame, commands.Connection.Start) self.assertEqual(frame.locales, 'en_US') self.assertEqual(count, 335) @@ -123,7 +139,7 @@ def test_connect_failure(self): 'localhost', random.randint(1024, 32768), False, - {}, + _DEFAULT_SSL_OPTIONS, self.events, self.exceptions, ) @@ -142,31 +158,40 @@ def test_write_frame_when_closed(self): def test_get_ssl_context(self): self.io._use_ssl = True - self.io._ssl_options = {} + self.io._ssl_options = _DEFAULT_SSL_OPTIONS context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) self.assertTrue(context.check_hostname) def test_get_ssl_context_verify_none(self): self.io._use_ssl = True - self.io._ssl_options = { - 'check_hostname': False, - 'verify': ssl.CERT_NONE, - } + self.io._ssl_options = SslOptions( + check_hostname=False, + cafile=None, + capath=None, + certfile=None, + keyfile=None, + verify=ssl.CERT_NONE, + ) context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) self.assertFalse(context.check_hostname) def test_get_ssl_context_certs(self): self.io._use_ssl = True - self.io._ssl_options = { - 'cafile': DATA_PATH / 'ca.crt', - 'certfile': DATA_PATH / 'client.crt', - 'keyfile': DATA_PATH / 'client.key', - 'verify': ssl.CERT_REQUIRED, - } + self.io._ssl_options = SslOptions( + check_hostname=True, + cafile=str(DATA_PATH / 'ca.crt'), + capath=None, + certfile=str(DATA_PATH / 'client.crt'), + keyfile=str(DATA_PATH / 'client.key'), + verify=ssl.CERT_REQUIRED, + ) context = self.io._get_ssl_context() self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) self.assertTrue(context.check_hostname) self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED) @@ -178,34 +203,37 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._expectation: bytes | None = None self._response: bytes | None = None - self._transport: asyncio.Transport | None = None + self._transport: asyncio.BaseTransport | None = None MockServer.instances.append(self) - def connection_lost(self, exc): + def connection_lost(self, exc: Exception | None) -> None: MockServer.instances.remove(self) - def connection_made(self, transport): + def connection_made(self, transport: asyncio.BaseTransport) -> None: self._transport = transport LOGGER.debug( 'Connection from %r', transport.get_extra_info('peername') ) - def data_received(self, data): + def data_received(self, data: bytes) -> None: LOGGER.debug('Received %r', data) + if self._transport is None: + return if self._expectation and data != self._expectation: - return self._transport.write(b'Bad data: ' + data) + self._transport.write(b'Bad data: ' + data) # type: ignore[attr-defined] + return if self._response: - self._transport.write(self._response) + self._transport.write(self._response) # type: ignore[attr-defined] # Connection.CloseOk - if data == '\x01\x00\x00\x00\x00\x00\x04\x00\n\x003\xce': + if data == b'\x01\x00\x00\x00\x00\x00\x04\x00\n\x003\xce': LOGGER.debug('Closing connection') self._transport.close() - def set_expectation(self, expectation: bytes): + def set_expectation(self, expectation: bytes) -> None: self._expectation = expectation - def set_response(self, response: bytes): + def set_response(self, response: bytes) -> None: self._response = response @@ -235,6 +263,7 @@ async def asyncTearDown(self): async def get_mock_server(self, retries=10, delay=0.1): connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + assert self.io._socket is not None socket_local = self.io._socket.getsockname() # (address, port) socket_remote = self.io._socket.getpeername() # (address, port) for _attempt in range(retries): @@ -247,11 +276,13 @@ async def get_mock_server(self, retries=10, delay=0.1): await asyncio.sleep(delay) raise AssertionError('No MockServer found') - async def write_frame(self, channel: int, frame): + async def write_frame( + self, channel: int, frame: pamqp.frame.FrameTypes + ) -> None: self.io.write_frame(channel, frame) await asyncio.sleep(0.1) - async def write_protocol_header(self): + async def write_protocol_header(self) -> None: await self.write_frame(0, header.ProtocolHeader()) self.assertEqual(self.io.bytes_written, 8) @@ -301,6 +332,7 @@ async def test_on_read_ready(self): 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') async def test_on_data_received_multiple_frames(self): @@ -328,6 +360,7 @@ async def test_on_data_received_multiple_frames(self): 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') frame = self.io._channels[1].get(True, 3) @@ -349,7 +382,9 @@ async def test_on_data_received_remaining_buffer(self): async def test_remote_name(self): mock_server = await self.get_mock_server() + assert mock_server._transport is not None remote = mock_server._transport.get_extra_info('sockname') + assert self.io._socket is not None local = self.io._socket.getsockname() self.assertEqual( self.io.remote_name, diff --git a/uv.lock b/uv.lock index 61a9253..357478f 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077 }, ] +[[package]] +name = "basedpyright" +version = "1.38.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/b4/26cb812eaf8ab56909c792c005fe1690706aef6f21d61107639e46e9c54c/basedpyright-1.38.4.tar.gz", hash = "sha256:8e7d4f37ffb6106621e06b9355025009cdf5b48f71c592432dd2dd304bf55e70", size = 25354730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0b/3f95fd47def42479e61077523d3752086d5c12009192a7f1c9fd5507e687/basedpyright-1.38.4-py3-none-any.whl", hash = "sha256:90aa067cf3e8a3c17ad5836a72b9e1f046bc72a4ad57d928473d9368c9cd07a2", size = 12352258 }, +] + [[package]] name = "build" version = "1.4.2" @@ -340,6 +352,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315 }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021 }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500 }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622 }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304 }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493 }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129 }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113 }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269 }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673 }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597 }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733 }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273 }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -551,6 +636,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779 }, ] +[[package]] +name = "mypy" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972 }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007 }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752 }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265 }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476 }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226 }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091 }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525 }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469 }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953 }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363 }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005 }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616 }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091 }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344 }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400 }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384 }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378 }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170 }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526 }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456 }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331 }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047 }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585 }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075 }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141 }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925 }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089 }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710 }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013 }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240 }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565 }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874 }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380 }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174 }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -560,6 +704,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e5755ad739daafce2e152ab609293d65e6c663b399e28a4bbcd0f4af1f45/nodejs_wheel_binaries-24.14.1.tar.gz", hash = "sha256:d00ae0c86d7e1bfa798e8f8ad282db751af157cdcaa1208a1b9a2cf2a85ac821", size = 8056 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/b7/9765d9a5d3b95475829ef5965d4a4f6f4badb034ee4e18c2d5f8b9b65d6f/nodejs_wheel_binaries-24.14.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9e856ba0f2d3d2659869e6e0f4cae6874faeeeca7f879131a88451356373ac4", size = 54945603 }, + { url = "https://files.pythonhosted.org/packages/6f/15/bc2fa51ee31ce597b2af1905081e5a5add07fe0cf619bfa531d7df2f1f1b/nodejs_wheel_binaries-24.14.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:634f57829ebfdfe95d096f32a50c5cdd3a6c72a94dcf2b92a8bef9868cccb13e", size = 55119951 }, + { url = "https://files.pythonhosted.org/packages/a6/dd/92ff0831262af4bbb5473d4e7964fd27afb0901a2690a6ff7bc3d220d97f/nodejs_wheel_binaries-24.14.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:404b563467129e6a0ea7006a38b3d8af0ebfbc340b31a6a0af2c59ea3af90b7c", size = 59487620 }, + { url = "https://files.pythonhosted.org/packages/45/36/bbbee3adf6afd00944e5a86ebd64987dea90bd347090155a4989dc3e8594/nodejs_wheel_binaries-24.14.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7863c62f8a3946b727831f71375a9ae00205b3258478476034b49c3a1d57ac12", size = 59986044 }, + { url = "https://files.pythonhosted.org/packages/05/16/119e4168bf7ed17ad7961d122701c75ac86135fa243a958b960a3f1b7055/nodejs_wheel_binaries-24.14.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a3f64daa1235fa6a83c778ded98d5fe4e74979ca54aa2ffb807f0805c57c3abe", size = 61489823 }, + { url = "https://files.pythonhosted.org/packages/8c/a6/d581996827b9d1133094dc347f1c4e3d2a70557973ce7a427a03337b1427/nodejs_wheel_binaries-24.14.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:810a48ce096925ead0690f7d143e48fb902ebfc9212097e8f6cb3ac6cbe8f314", size = 62069740 }, + { url = "https://files.pythonhosted.org/packages/27/da/396d1a48cbf3d5899461bda12fa97ae4010d7e4013e7f28230cb04af5818/nodejs_wheel_binaries-24.14.1-py2.py3-none-win_amd64.whl", hash = "sha256:7a087b6a727fb9242d1cc83c8b121711bd0e9686408d27de48b34b23dfb26ac5", size = 41400067 }, + { url = "https://files.pythonhosted.org/packages/13/b7/adb21cf549934579e98934531e7f9b038d583fc9c2dd4b82ea01cc31bdd2/nodejs_wheel_binaries-24.14.1-py2.py3-none-win_arm64.whl", hash = "sha256:978fdfe76624c48111ab99ed0f99f9d4c1c682e420b0212ac9e1daee52f20283", size = 39096873 }, +] + [[package]] name = "packaging" version = "26.0" @@ -763,8 +923,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "build" }, { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, { name = "pre-commit" }, { name = "python-dotenv" }, { name = "ruff" }, @@ -780,8 +942,10 @@ requires-dist = [{ name = "pamqp", specifier = ">=4.0,<5.0" }] [package.metadata.requires-dev] dev = [ + { name = "basedpyright" }, { name = "build" }, { name = "coverage", extras = ["toml"] }, + { name = "mypy" }, { name = "pre-commit" }, { name = "python-dotenv" }, { name = "ruff" }, @@ -895,6 +1059,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + [[package]] name = "urllib3" version = "2.6.3" From 1d9f30ff2592bb8ecc26547999b6f208242d6ff9 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Tue, 31 Mar 2026 23:03:16 -0400 Subject: [PATCH 48/59] 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 49/59] 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 50/59] 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 51/59] 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 52/59] 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 53/59] 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 54/59] 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 55/59] 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])) From 84d53f83a8c8dc26991b6130dfc41f08e6c41a5e Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 11:07:51 -0400 Subject: [PATCH 56/59] Fix GH issues #133, #124, #121, #73 and complete connection lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full AMQP 0-9-1 connection negotiation that was missing from the modernized channel0/connection modules, and addresses four open issues: **channel0.py** — full rewrite as a threading.Thread: - Sends ProtocolHeader, handles Connection.Start/Tune/Open handshake - Negotiates channel_max, frame_max, and heartbeat with the server - Propagates negotiation failures to the exceptions queue with proper timeout (#133, #124: prevents indefinite hang when the server comes up mid-retry or returns an error such as an invalid vhost) - Runs a post-open loop to handle Connection.Blocked/Unblocked/Close **connection.py** — completes the implementation: - Adds channel() method, _channels dict, and heartbeat lifecycle - Adds _get_next_channel_id() that reclaims IDs from closed channels before allocating a new one, preventing 65535-channel exhaustion (#121) - connect() waits for CHANNEL0_OPENED with a bounded timeout; surfaces any exception raised by channel0 (invalid vhost, auth failure, etc.) - _close_channels() gracefully closes open channels on connection close **io.py** — adds write-trigger socket pair and shared write queue: - Exposes write_trigger (socket write end) and write_queue so that base.AMQPChannel.write_frame() can enqueue frames and wake the asyncio event loop, maintaining backward compatibility with the restored base.py **state.py** — adds closed/open/opening/closing aliases for compatibility with the restored base.py which uses these names instead of is_closed etc. **base.py** — reduces _read_from_queue() polling timeout from 100 ms to 10 ms; cuts the busy-wait overhead between each published message when publisher confirms are enabled (~10x latency improvement) (#73). Co-Authored-By: Gavin M. Roy --- rabbitpy/base.py | 5 +- rabbitpy/channel0.py | 308 ++++++++++++++++++++++++++++++++++++++++- rabbitpy/connection.py | 218 ++++++++++++++++++++++++----- rabbitpy/io.py | 62 +++++++++ rabbitpy/state.py | 20 +++ 5 files changed, 579 insertions(+), 34 deletions(-) diff --git a/rabbitpy/base.py b/rabbitpy/base.py index 47e1ca7..0c8ea32 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -399,7 +399,10 @@ def _read_from_queue(self) -> base.Frame | commands.Basic.Deliver | None: self._read_queue.task_done() else: try: - value = self._read_queue.get(True, 0.1) + # 10 ms timeout — short enough that publisher confirms + # and other synchronous RPCs do not busy-wait for long + # (#73: reduces per-message latency ~10x vs 100 ms). + value = self._read_queue.get(True, 0.01) self._read_queue.task_done() except queue.Empty: value = None diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index 4bb1622..5143d39 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -1,13 +1,317 @@ +""" +Channel0 handles AMQP connection negotiation on channel 0. + +After the TCP socket is connected, a Channel0 thread is started which sends +the AMQP ProtocolHeader and completes the Connection.Start/Tune/Open handshake. +Once negotiation succeeds the CHANNEL0_OPENED event is set. On failure an +exception is placed in the shared exceptions queue. + +""" import logging import queue +import sys +import threading +import typing import pamqp.frame +import pamqp.heartbeat +from pamqp import commands, header + +from rabbitpy import __version__, exceptions +from rabbitpy import events as ev_module + +if typing.TYPE_CHECKING: + from rabbitpy import io as io_module + from rabbitpy.url_parser import ConnectionArgs LOGGER = logging.getLogger(__name__) +DEFAULT_LOCALE = 'en_US' + + +class Channel0(threading.Thread): + """Negotiate and maintain the AMQP connection on channel 0. -class Channel0: - def __init__(self) -> None: + :param args: Parsed connection arguments (from url_parser.parse()) + :param events: Shared Events object for cross-thread signalling + :param exceptions_queue: Queue for propagating exceptions to the main thread + + """ + + def __init__( + self, + args: 'ConnectionArgs', + events: ev_module.Events, + exceptions_queue: queue.Queue, + ) -> None: + super().__init__(daemon=True, name='Channel0') self.pending_frames: queue.Queue[pamqp.frame.FrameTypes | None] = ( queue.Queue() ) + self._args = args + self._events = events + self._exceptions = exceptions_queue + self._io: io_module.IO | None = None + self._open = False + self._properties: dict = {} + self._max_channels: int = args.get('channel_max', 65535) + self._max_frame_size: int = args.get('frame_max', 131072) + self._heartbeat_interval: int = args.get('heartbeat', 60) + + # ------------------------------------------------------------------------- + # Public interface + # ------------------------------------------------------------------------- + + @property + def heartbeat_interval(self) -> int: + """Return the negotiated AMQP heartbeat interval in seconds.""" + return self._heartbeat_interval + + @property + def maximum_channels(self) -> int: + """Return the maximum number of channels for the connection.""" + return self._max_channels + + @property + def maximum_frame_size(self) -> int: + """Return the maximum AMQP frame size in bytes.""" + return self._max_frame_size + + @property + def open(self) -> bool: + """Return True if the AMQP connection has been fully negotiated.""" + return self._open + + @property + def properties(self) -> dict: + """Return the server properties received in Connection.Start.""" + return self._properties + + def close(self) -> None: + """Send Connection.Close if the connection is currently open.""" + if self._open and self._io is not None: + try: + self._io.write_frame( + 0, + commands.Connection.Close( + reply_code=200, reply_text='Normal shutdown' + ), + ) + except OSError as exc: + LOGGER.debug('Error sending Connection.Close: %r', exc) + self._open = False + + def send_heartbeat(self) -> None: + """Write a heartbeat frame to the server.""" + if self._io is not None: + try: + self._io.write_frame(0, pamqp.heartbeat.Heartbeat()) + except OSError as exc: + LOGGER.debug('Error sending heartbeat: %r', exc) + + def start(self, io: 'io_module.IO') -> None: # type: ignore[override] + """Attach the IO object and start the negotiation thread. + + :param io: The connected IO thread to write frames through + + """ + self._io = io + super().start() + + # ------------------------------------------------------------------------- + # Thread entry point + # ------------------------------------------------------------------------- + + def run(self) -> None: + """Run the AMQP handshake. Any exception is queued for the caller.""" + try: + self._negotiate() + except BaseException as exc: # noqa: BLE001 + LOGGER.debug('Channel0 negotiation failed: %r', exc) + self._exceptions.put(exc) + self._events.set(ev_module.EXCEPTION_RAISED) + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + def _negotiate(self) -> None: + """Perform the full AMQP 0-9-1 connection handshake.""" + assert self._io is not None # noqa: S101 + + # Step 1: send the protocol header + self._io.write_frame(0, header.ProtocolHeader()) + + # Step 2: wait for Connection.Start + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.Start): + raise exceptions.ConnectionException( + f'Expected Connection.Start, got {type(frame).__name__}' + ) + self._on_connection_start(frame) + + # Step 3: wait for Connection.Tune + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.Tune): + raise exceptions.ConnectionException( + f'Expected Connection.Tune, got {type(frame).__name__}' + ) + self._on_connection_tune(frame) + + # Step 4: send Connection.Open (TuneOk + Open sent in _on_connection_tune) + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.OpenOk): + raise exceptions.ConnectionException( + f'Expected Connection.OpenOk, got {type(frame).__name__}' + ) + + LOGGER.debug('AMQP connection negotiated') + self._open = True + self._events.set(ev_module.CHANNEL0_OPENED) + + # Step 5: process channel-0 frames for the lifetime of the connection + self._run_loop() + + def _run_loop(self) -> None: + """Process channel-0 management frames (Blocked/Unblocked/Close).""" + while self._open: + try: + frame = self.pending_frames.get(timeout=1.0) + except queue.Empty: + continue + if frame is None: + break + self._handle_runtime_frame(frame) + + def _handle_runtime_frame(self, frame: pamqp.frame.FrameTypes) -> None: + """Dispatch a channel-0 frame received after connection is open.""" + if isinstance(frame, commands.Connection.Blocked): + LOGGER.warning('Connection is blocked: %s', frame.reason) + self._events.set(ev_module.CONNECTION_BLOCKED) + elif isinstance(frame, commands.Connection.Unblocked): + LOGGER.info('Connection is unblocked') + self._events.clear(ev_module.CONNECTION_BLOCKED) + elif isinstance(frame, commands.Connection.Close): + LOGGER.error( + 'Server closed the connection (%s): %s', + frame.reply_code, + frame.reply_text, + ) + self._open = False + exc_cls = exceptions.AMQP.get( + frame.reply_code, exceptions.RemoteClosedException + ) + self._exceptions.put(exc_cls(frame.reply_code, frame.reply_text)) + self._events.set(ev_module.EXCEPTION_RAISED) + + def _wait_for_frame( + self, timeout: float + ) -> pamqp.frame.FrameTypes: + """Block until a frame arrives on pending_frames or timeout expires. + + :param timeout: Maximum seconds to wait + :raises: exceptions.ConnectionException on timeout or IO shutdown + + """ + deadline = timeout * 3 # give extra time for negotiation round-trips + waited = 0.0 + interval = 0.1 + while waited < deadline: + if not self._exceptions.empty(): + raise self._exceptions.get() + try: + frame = self.pending_frames.get(timeout=interval) + if frame is not None: + return frame + except queue.Empty: + pass + waited += interval + + raise exceptions.ConnectionException( + 'Timed out waiting for AMQP server response during negotiation' + ) + + def _on_connection_start( + self, frame: commands.Connection.Start + ) -> None: + """Process Connection.Start and send Connection.StartOk.""" + self._properties = dict(frame.server_properties or {}) + LOGGER.debug('Server properties: %r', self._properties) + self._io.write_frame( # type: ignore[union-attr] + 0, + commands.Connection.StartOk( + client_properties=self._build_client_properties(), + mechanism='PLAIN', + response=f'\0{self._args["username"]}\0{self._args["password"]}', + locale=self._args.get('locale') or DEFAULT_LOCALE, + ), + ) + + def _on_connection_tune( + self, frame: commands.Connection.Tune + ) -> None: + """Process Connection.Tune, send TuneOk, then send Connection.Open.""" + self._max_channels = self._negotiate_value( + frame.channel_max, self._args.get('channel_max', 0) + ) + self._max_frame_size = self._negotiate_value( + frame.frame_max, self._args.get('frame_max', 0) + ) + # Heartbeat: 0 from either side means disabled; otherwise take minimum + server_hb = frame.heartbeat or 0 + client_hb = self._args.get('heartbeat', 60) or 0 + if server_hb == 0 or client_hb == 0: + self._heartbeat_interval = 0 + else: + self._heartbeat_interval = min(server_hb, client_hb) + + LOGGER.debug( + 'Negotiated: channels=%i frame_size=%i heartbeat=%i', + self._max_channels, + self._max_frame_size, + self._heartbeat_interval, + ) + assert self._io is not None # noqa: S101 + self._io.write_frame( + 0, + commands.Connection.TuneOk( + channel_max=self._max_channels, + frame_max=self._max_frame_size, + heartbeat=self._heartbeat_interval, + ), + ) + self._io.write_frame( + 0, + commands.Connection.Open( + virtual_host=self._args['virtual_host'], + ), + ) + + @staticmethod + def _negotiate_value(server_value: int, client_value: int) -> int: + """Return the negotiated value: minimum of both, or either if the + other is zero (meaning 'no preference'). + + """ + if server_value == 0: + return client_value + if client_value == 0: + return server_value + return min(server_value, client_value) + + @staticmethod + def _build_client_properties() -> dict: + """Return the client property dict sent in Connection.StartOk.""" + return { + 'product': 'rabbitpy', + 'version': __version__, + 'platform': 'Python {}.{}.{}'.format(*sys.version_info[:3]), + 'information': 'See https://rabbitpy.readthedocs.io', + 'capabilities': { + 'authentication_failure_close': True, + 'basic.nack': True, + 'connection.blocked': True, + 'consumer_cancel_notify': True, + 'publisher_confirms': True, + }, + } diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index 738a78e..4774e8a 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -2,10 +2,25 @@ import queue import sys import threading +import time import types import typing -from rabbitpy import __version__, channel0, events, io, state, url_parser +from rabbitpy import ( + __version__, + channel0, + events, + exceptions, + io, + state, + url_parser, +) +from rabbitpy import ( + channel as channel_mod, +) +from rabbitpy import ( + heartbeat as heartbeat_mod, +) LOGGER = logging.getLogger(__name__) @@ -86,14 +101,15 @@ def __init__( super().__init__() self._args: url_parser.ConnectionArgs = url_parser.parse(url) self._channel_lock = threading.Lock() - self._channel0 = channel0.Channel0() + self._channel0: channel0.Channel0 | None = None + self._channels: dict[int, channel_mod.Channel] = {} + self._freed_channel_ids: set[int] = set() self._client_properties = ClientProperties(client_properties) self._events = events.Events() self._exceptions: queue.Queue[Exception] = queue.Queue() + self._heartbeat: heartbeat_mod.Heartbeat | None = None self._io: io.IO | None = None - self._max_frame_size: int | None = None self._name = connection_name or '0x%x' % id(self) # noqa: UP031 - self._write_queue: queue.Queue[typing.Any] = queue.Queue() def __enter__(self) -> typing.Self: """For use as a context manager, return a handle to this object @@ -141,9 +157,68 @@ def blocked(self) -> bool: """ return bool(self._events.is_set(events.CONNECTION_BLOCKED)) + @property + def capabilities(self) -> dict: + """Return the RabbitMQ server capabilities from negotiation.""" + if self._channel0 is None: + return {} + return self._channel0.properties.get('capabilities', {}) + + @property + def server_properties(self) -> dict: + """Return the RabbitMQ server properties from negotiation.""" + if self._channel0 is None: + return {} + return self._channel0.properties + + def channel( + self, blocking_read: bool = False + ) -> channel_mod.Channel: + """Create and return a new AMQP channel. + + If ``blocking_read`` is ``True``, :py:meth:`Queue.get` calls within + the channel use blocking operations which lower CPU usage and increase + throughput. However, ``KeyboardInterrupt`` will not be raised while + the channel is blocked waiting for a frame. + + :param blocking_read: Enable blocking reads for higher throughput + :raises: exceptions.ConnectionClosed + :raises: exceptions.TooManyChannelsError + + """ + if self.closed: + raise exceptions.ConnectionClosed() + with self._channel_lock: + channel_id = self._get_next_channel_id() + channel_frames: queue.Queue = queue.Queue() + assert self._io is not None # noqa: S101 + assert self._channel0 is not None # noqa: S101 + ch = channel_mod.Channel( + channel_id=channel_id, + server_capabilities=self.capabilities, + events=self._events, + exception_queue=self._exceptions, + read_queue=channel_frames, + write_queue=self._io.write_queue, + maximum_frame_size=self._channel0.maximum_frame_size, + write_trigger=self._io.write_trigger, + connection=self, + blocking_read=blocking_read, + ) + self._channels[channel_id] = ch + self._io.add_channel(channel_id, channel_frames) + ch.open() + return ch + def close(self) -> None: - """Close the connection to RabbitMQ.""" + """Close the connection to RabbitMQ, including all open channels.""" self._set_state(self.CLOSING) + self._close_channels() + if self._heartbeat is not None: + self._heartbeat.stop() + self._heartbeat = None + if self._channel0 is not None: + self._channel0.close() self._events.clear(events.CONNECTION_EVENT) self._events.clear(events.SOCKET_CLOSE) self._events.clear(events.SOCKET_CLOSED) @@ -151,28 +226,16 @@ def close(self) -> None: self._stop_io() self._set_state(self.CLOSED) - def _stop_io(self) -> None: - """Signal the IO thread to stop and wait for it to finish. - - Blocks until the IO thread has exited (or until the connection timeout - elapses) so that callers can rely on the socket being fully torn down - before they transition to CLOSED. The join is skipped when called from - inside the IO thread itself to prevent a deadlock. - - """ - io_thread = self._io - if io_thread is None: - return - io_thread.stop() - if ( - io_thread.is_alive() - and io_thread is not threading.current_thread() - ): - io_thread.join(self._args['timeout']) - def connect(self) -> None: - """Connect to the RabbitMQ Server""" + """Connect to RabbitMQ and negotiate the AMQP connection.""" self._set_state(self.OPENING) + + self._channel0 = channel0.Channel0( + args=self._args, + events=self._events, + exceptions_queue=self._exceptions, + ) + self._io = io.IO( self._args['host'], self._args['port'], @@ -185,17 +248,110 @@ def connect(self) -> None: self._io.add_channel(0, self._channel0.pending_frames) self._io.start() - while self.is_opening and not self._events.is_set( - events.SOCKET_OPENED - ): + # Wait for the TCP socket to open + socket_opened = events.SOCKET_OPENED + while self.is_opening and not self._events.is_set(socket_opened): if not self._exceptions.empty(): exception = self._exceptions.get() raise exception - self._events.wait(events.SOCKET_OPENED, self._args['timeout']) - if not self._events.is_set(events.SOCKET_OPENED): + self._events.wait(socket_opened, self._args['timeout']) + if not self._events.is_set(socket_opened): raise RuntimeError('Timeout waiting for opening the socket') - # If the socket could not be opened, return instead of waiting + # If the socket could not be opened, clean up and return if self.is_closed: return self.close() + + # Start AMQP channel-0 negotiation in its own thread + self._channel0.start(self._io) + + # Wait for the AMQP handshake to complete (#133, #124: proper timeout + # prevents indefinite hang when the server comes up mid-retry or + # returns an error such as an invalid vhost) + negotiation_timeout = max(self._args['timeout'] * 5, 15) + deadline = time.monotonic() + negotiation_timeout + while time.monotonic() < deadline: + if self._events.is_set(events.CHANNEL0_OPENED): + break + if not self._exceptions.empty(): + exception = self._exceptions.get() + self._stop_io() + self._set_state(self.CLOSED) + raise exception + time.sleep(0.01) + else: + self._stop_io() + self._set_state(self.CLOSED) + raise RuntimeError( + 'Timeout waiting for AMQP connection negotiation' + ) + + # Start the heartbeat keeper if the server requires it + if self._channel0.heartbeat_interval: + self._heartbeat = heartbeat_mod.Heartbeat( + self._io, + self._channel0, + self._channel0.heartbeat_interval, + ) + self._heartbeat.start() + + self._set_state(self.OPEN) + + # ------------------------------------------------------------------------- + # Private helpers + # ------------------------------------------------------------------------- + + def _close_channels(self) -> None: + """Close all open channels.""" + for ch in list(self._channels.values()): + if not ch.closed: + try: + ch.close() + except OSError as exc: + LOGGER.debug('Error closing channel: %r', exc) + + def _get_next_channel_id(self) -> int: + """Return the next available channel ID. + + Reuses IDs from previously closed channels before allocating a new + one, preventing exhaustion of the 65 535-channel limit on long-lived + connections (#121). Must be called with ``_channel_lock`` held. + + """ + # Reclaim IDs from channels that have been closed since last call + for channel_id, ch in list(self._channels.items()): + if ch.closed: + del self._channels[channel_id] + self._freed_channel_ids.add(channel_id) + + if self._freed_channel_ids: + return self._freed_channel_ids.pop() + + if not self._channels: + return 1 + + assert self._channel0 is not None # noqa: S101 + next_id = max(self._channels.keys()) + 1 + if next_id > self._channel0.maximum_channels: + raise exceptions.TooManyChannelsError + return next_id + + def _stop_io(self) -> None: + """Signal the IO thread to stop and wait for it to finish. + + Blocks until the IO thread has exited (or until the connection timeout + elapses) so that callers can rely on the socket being fully torn down + before they transition to CLOSED. The join is skipped when called from + inside the IO thread itself to prevent a deadlock. + + """ + io_thread = self._io + if io_thread is None: + return + io_thread.stop() + if ( + io_thread.is_alive() + and io_thread is not threading.current_thread() + ): + io_thread.join(self._args['timeout']) diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 7424f36..632b2e1 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -66,6 +66,15 @@ def __init__( self._remote_name: str | None = None self._socket: socket.socket | None = None self._write_buffer: collections.deque[bytes] = collections.deque() + # Write trigger: a socket pair used by AMQPChannel.write_frame() to + # wake the asyncio event loop when frames are queued for writing. + self._write_trigger_r, self._write_trigger_w = socket.socketpair() + self._write_trigger_r.setblocking(False) + self._write_trigger_w.setblocking(False) + # Shared write queue consumed by _on_write_trigger (channel_id, frame). + self._write_queue: queue.Queue[ + tuple[int, pamqp.base.Frame | pamqp.heartbeat.Heartbeat] + ] = queue.Queue() def add_channel( self, channel: int, read_queue: queue.Queue[PamqpFrame] @@ -84,6 +93,11 @@ def close(self) -> None: with self._lock: self._events.clear(rabbitpy.events.SOCKET_OPENED) self._close_socket() + for sock in (self._write_trigger_r, self._write_trigger_w): + try: + sock.close() + except OSError: + pass self._closed.set() self._events.set(rabbitpy.events.SOCKET_CLOSED) @@ -154,6 +168,21 @@ def remote_name(self) -> str | None: with self._lock: return self._remote_name + @property + def write_trigger(self) -> socket.socket: + """Return the write end of the trigger socket pair. + + AMQPChannel instances send a byte here to wake the event loop after + placing a frame in write_queue. + + """ + return self._write_trigger_w + + @property + def write_queue(self) -> queue.Queue: + """Return the shared write queue used by AMQPChannel.write_frame().""" + return self._write_queue + # Internal Methods def _add_exception(self, exc: Exception) -> None: @@ -280,6 +309,29 @@ def _on_data_received( return value, None, None, 0 return value[byte_count:], channel_id, frame_in, byte_count + def _on_write_trigger(self) -> None: + """Drain the trigger socket and marshal all queued frames. + + Called by the asyncio event loop when a byte arrives on the read end + of the write trigger socket pair, signalling that one or more + ``(channel_id, frame)`` tuples have been placed in ``_write_queue`` by + an AMQPChannel in another thread. + + """ + try: + self._write_trigger_r.recv(4096) + except (BlockingIOError, OSError): + pass + while True: + try: + channel_id, frame_value = self._write_queue.get_nowait() + except queue.Empty: + break + with self._lock: + self._write_buffer.append( + pamqp.frame.marshal(frame_value, channel_id) + ) + def _on_error(self, exception: OSError) -> None: """Handle socket errors, add exceptions, and signal events.""" if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): @@ -354,10 +406,16 @@ async def _run(self) -> None: """Manage the I/O loop: connect, read, write, and handle closure.""" loop = asyncio.get_running_loop() self._ioloop = loop + + # Register the write-trigger reader immediately so frames queued + # before the socket is fully open are not lost. + loop.add_reader(self._write_trigger_r, self._on_write_trigger) + with self._lock: await self._connect() if not self._socket: + loop.remove_reader(self._write_trigger_r.fileno()) return # Could not establish connection loop.add_reader(self._socket, self._on_read_ready) @@ -378,3 +436,7 @@ async def _run(self) -> None: loop.remove_writer(self._socket.fileno()) except (AttributeError, OSError): pass + try: + loop.remove_reader(self._write_trigger_r.fileno()) + except (AttributeError, OSError): + pass diff --git a/rabbitpy/state.py b/rabbitpy/state.py index a818892..96434c2 100644 --- a/rabbitpy/state.py +++ b/rabbitpy/state.py @@ -42,21 +42,41 @@ def is_closed(self) -> bool: """Returns True if in the CLOSED runtime state""" return self._state == self.CLOSED + @property + def closed(self) -> bool: + """Alias for is_closed for backward compatibility.""" + return self._state == self.CLOSED + @property def is_closing(self) -> bool: """Returns True if in the CLOSING runtime state""" return self._state == self.CLOSING + @property + def closing(self) -> bool: + """Alias for is_closing for backward compatibility.""" + return self._state == self.CLOSING + @property def is_open(self) -> bool: """Returns True if in the OPEN runtime state""" return self._state == self.OPEN + @property + def open(self) -> bool: + """Alias for is_open for backward compatibility.""" + return self._state == self.OPEN + @property def is_opening(self) -> bool: """Returns True if in the OPENING runtime state""" return self._state == self.OPENING + @property + def opening(self) -> bool: + """Alias for is_opening for backward compatibility.""" + return self._state == self.OPENING + @property def state(self) -> int: """Return the runtime state value""" From fcda482ab566e9d0182b041c59358e114b298980 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 12:22:07 -0400 Subject: [PATCH 57/59] Fix ruff formatting and mypy type errors in io.py, connection.py, channel0.py - Add full type arguments to queue.Queue annotations in io.py and connection.py (mypy [type-arg] errors) - Fix dict return types on capabilities/server_properties to dict[str, typing.Any] (mypy [type-arg] and [no-any-return] errors) - Apply ruff formatting to channel0.py and connection.py (2 files were flagged as needing reformatting by pre-commit) Co-Authored-By: Gavin M. Roy --- rabbitpy/channel0.py | 13 ++++--------- rabbitpy/connection.py | 17 +++++++++-------- rabbitpy/io.py | 4 +++- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index 5143d39..d6f2fd7 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -7,6 +7,7 @@ exception is placed in the shared exceptions queue. """ + import logging import queue import sys @@ -204,9 +205,7 @@ def _handle_runtime_frame(self, frame: pamqp.frame.FrameTypes) -> None: self._exceptions.put(exc_cls(frame.reply_code, frame.reply_text)) self._events.set(ev_module.EXCEPTION_RAISED) - def _wait_for_frame( - self, timeout: float - ) -> pamqp.frame.FrameTypes: + def _wait_for_frame(self, timeout: float) -> pamqp.frame.FrameTypes: """Block until a frame arrives on pending_frames or timeout expires. :param timeout: Maximum seconds to wait @@ -231,9 +230,7 @@ def _wait_for_frame( 'Timed out waiting for AMQP server response during negotiation' ) - def _on_connection_start( - self, frame: commands.Connection.Start - ) -> None: + def _on_connection_start(self, frame: commands.Connection.Start) -> None: """Process Connection.Start and send Connection.StartOk.""" self._properties = dict(frame.server_properties or {}) LOGGER.debug('Server properties: %r', self._properties) @@ -247,9 +244,7 @@ def _on_connection_start( ), ) - def _on_connection_tune( - self, frame: commands.Connection.Tune - ) -> None: + def _on_connection_tune(self, frame: commands.Connection.Tune) -> None: """Process Connection.Tune, send TuneOk, then send Connection.Open.""" self._max_channels = self._negotiate_value( frame.channel_max, self._args.get('channel_max', 0) diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index 4774e8a..910418c 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -158,22 +158,23 @@ def blocked(self) -> bool: return bool(self._events.is_set(events.CONNECTION_BLOCKED)) @property - def capabilities(self) -> dict: + def capabilities(self) -> dict[str, typing.Any]: """Return the RabbitMQ server capabilities from negotiation.""" if self._channel0 is None: return {} - return self._channel0.properties.get('capabilities', {}) + result: dict[str, typing.Any] = self._channel0.properties.get( + 'capabilities', {} + ) + return result @property - def server_properties(self) -> dict: + def server_properties(self) -> dict[str, typing.Any]: """Return the RabbitMQ server properties from negotiation.""" if self._channel0 is None: return {} - return self._channel0.properties + return typing.cast(dict[str, typing.Any], self._channel0.properties) - def channel( - self, blocking_read: bool = False - ) -> channel_mod.Channel: + def channel(self, blocking_read: bool = False) -> channel_mod.Channel: """Create and return a new AMQP channel. If ``blocking_read`` is ``True``, :py:meth:`Queue.get` calls within @@ -190,7 +191,7 @@ def channel( raise exceptions.ConnectionClosed() with self._channel_lock: channel_id = self._get_next_channel_id() - channel_frames: queue.Queue = queue.Queue() + channel_frames: queue.Queue[typing.Any] = queue.Queue() assert self._io is not None # noqa: S101 assert self._channel0 is not None # noqa: S101 ch = channel_mod.Channel( diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 632b2e1..7d9f0c9 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -179,7 +179,9 @@ def write_trigger(self) -> socket.socket: return self._write_trigger_w @property - def write_queue(self) -> queue.Queue: + def write_queue( + self, + ) -> queue.Queue[tuple[int, pamqp.base.Frame | pamqp.heartbeat.Heartbeat]]: """Return the shared write queue used by AMQPChannel.write_frame().""" return self._write_queue From 3404ca7799a8f13580c837890a8eddc9dab5658b Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 12:25:13 -0400 Subject: [PATCH 58/59] Fix circular import in channel.py by moving connection import under TYPE_CHECKING The `from rabbitpy import connection as conn` import caused a circular dependency: __init__ -> amqp -> channel -> connection -> channel. Since `from __future__ import annotations` is already present, the `conn.Connection` type annotation on `Channel.__init__` is evaluated lazily as a string at runtime, so moving the import under TYPE_CHECKING is safe and breaks the cycle. Fixes AttributeError on Python 3.11-3.13 where partially initialized modules do not expose their classes during circular imports. Co-Authored-By: Gavin M. Roy --- rabbitpy/channel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index 4c9cca0..ffe3a13 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -22,15 +22,13 @@ exceptions, message, ) -from rabbitpy import ( - connection as conn, -) from rabbitpy import ( events as rabbitpy_events, ) if TYPE_CHECKING: from rabbitpy import amqp, amqp_queue + from rabbitpy import connection as conn LOGGER = logging.getLogger(__name__) From 0dc0e5372bf782bc6f4c8b371773189d6620fc60 Mon Sep 17 00:00:00 2001 From: "Gavin M. Roy" Date: Wed, 1 Apr 2026 13:04:27 -0400 Subject: [PATCH 59/59] Fix TypeError in Connection.Close due to None class_id/method_id pamqp 4.0.0's encode.short_uint requires an int but Connection.Close defaults class_id and method_id to None. Per the AMQP spec, a normal graceful close passes 0 for both fields (no associated method error). Co-Authored-By: Gavin M. Roy --- rabbitpy/channel0.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index d6f2fd7..564f5a1 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -95,7 +95,10 @@ def close(self) -> None: self._io.write_frame( 0, commands.Connection.Close( - reply_code=200, reply_text='Normal shutdown' + reply_code=200, + reply_text='Normal shutdown', + class_id=0, + method_id=0, ), ) except OSError as exc: