v1.3.5
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
########################################################################
|
||||
# File name: tracking.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
"""
|
||||
:mod:`~aioxmpp.tracking` --- Interfaces for high-level message tracking
|
||||
#######################################################################
|
||||
|
||||
This submodule provides interfaces for tracking messages to the recipient. The
|
||||
actual tracking is not implemented here.
|
||||
|
||||
.. versionadded:: 0.5
|
||||
|
||||
This module was added in version 0.5.
|
||||
|
||||
.. versionchanged:: 0.9
|
||||
|
||||
This module was completely rewritten in 0.9.
|
||||
|
||||
.. seealso::
|
||||
|
||||
Method :meth:`~.muc.Room.send_tracked_message`
|
||||
implements tracking for messages sent through a MUC.
|
||||
|
||||
.. _api-tracking-memory:
|
||||
|
||||
General Remarks about Tracking and Memory Consumption
|
||||
=====================================================
|
||||
|
||||
|
||||
Tracking stanzas costs memory. There are basically two options on how to
|
||||
implement the management of additional information:
|
||||
|
||||
1. Either the tracking stops when the :class:`MessageTracker` is released (i.e.
|
||||
the last reference to it gets collected).
|
||||
|
||||
2. Or the tracking is stopped explicitly by the user.
|
||||
|
||||
Option (1) has the appeal that users (applications) do not have to worry about
|
||||
properly releasing the tracking objects. However, it has the downside that
|
||||
applications have to keep the :class:`MessageeTracker` instance around.
|
||||
Remember that connecting to callbacks of an object is *not* enough to keep it
|
||||
alive.
|
||||
|
||||
Option (2) is somewhat like file objects work: in theory, you have to close
|
||||
them explicitly and manually: if you do not, there is no guarantee when the
|
||||
file is actually closed. It is thus a somewhat known Python idiom, and also is
|
||||
more explicit. And it doesn’t break callbacks.
|
||||
|
||||
The implementation of :class:`MessageTracker` uses **Option 2**. So you have to
|
||||
:meth:`MessageTracker.close` all :class:`MessageTracker` objects to ensure that
|
||||
all tracking resources associated with it are released; this stops any tracking
|
||||
which is still in progress.
|
||||
|
||||
It is strongly recommended that you close message trackers after a timeout. You
|
||||
can use :meth:`MessageTracker.set_timeout` for that, or manually call
|
||||
:meth:`MessageTracker.close` as desired.
|
||||
|
||||
Tracking implementations
|
||||
========================
|
||||
|
||||
.. autoclass:: BasicTrackingService
|
||||
|
||||
Interfaces
|
||||
==========
|
||||
|
||||
.. autoclass:: MessageTracker
|
||||
|
||||
.. autoclass:: MessageState
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
|
||||
import aioxmpp.callbacks
|
||||
import aioxmpp.service
|
||||
|
||||
|
||||
class MessageState(Enum):
|
||||
"""
|
||||
Enumeration of possible states for :class:`MessageTracker`. These states
|
||||
are used to inform using code about the delivery state of a message. See
|
||||
:class:`MessageTracker` for details.
|
||||
|
||||
.. versionchanged:: 0.10
|
||||
|
||||
The :attr:`ERROR` state is no longer final. Tracking implementations
|
||||
may now trump an error state with another state in some cases.
|
||||
|
||||
An example would be :xep:`184` message delivery receipts which can
|
||||
for sure attest an :attr:`DELIVERED_TO_RECIPIENT` state. This is more
|
||||
useful than an error reply.
|
||||
|
||||
.. attribute:: ABORTED
|
||||
|
||||
The message has been aborted or dropped in the :class:`~.StanzaStream`
|
||||
queues. See :class:`~.StanzaToken` and :attr:`MessageTracker.token`.
|
||||
|
||||
This is a final state.
|
||||
|
||||
.. attribute:: ERROR
|
||||
|
||||
An error reply stanza has been received for the stanza which was sent.
|
||||
|
||||
This is, in most cases, a final state, but transitions to
|
||||
:attr:`DELIVERED_TO_RECIPIENT` and :attr:`SEEN_BY_RECIPIENT` are
|
||||
allowed.
|
||||
|
||||
.. attribute:: IN_TRANSIT
|
||||
|
||||
The message is still queued for sending or has been sent to the peer
|
||||
server without stream management.
|
||||
|
||||
Depending on the tracking implementation, this may be a final state.
|
||||
|
||||
.. attribute:: DELIVERED_TO_SERVER
|
||||
|
||||
The message has been delivered to the server and the server acked the
|
||||
delivery using stream management.
|
||||
|
||||
Depending on the tracking implementation, this may be a final state.
|
||||
|
||||
.. attribute:: DELIVERED_TO_RECIPIENT
|
||||
|
||||
The message has been delivered to the recipient.
|
||||
|
||||
Depending on the tracking implementation, this may be a final state.
|
||||
|
||||
.. attribute:: SEEN_BY_RECIPIENT
|
||||
|
||||
The recipient has marked the message as seen or read. This is a final
|
||||
state.
|
||||
|
||||
"""
|
||||
|
||||
IN_TRANSIT = 0
|
||||
ABORTED = 1
|
||||
ERROR = 2
|
||||
DELIVERED_TO_SERVER = 3
|
||||
DELIVERED_TO_RECIPIENT = 4
|
||||
SEEN_BY_RECIPIENT = 5
|
||||
|
||||
|
||||
class MessageTracker:
|
||||
"""
|
||||
This is the high-level equivalent of the :class:`~.StanzaToken`.
|
||||
|
||||
This structure is used by different tracking implementations. The interface
|
||||
of this class is split in two parts:
|
||||
|
||||
1. The public interface for use by applications.
|
||||
2. The "protected" interface for use by tracking implementations.
|
||||
|
||||
:class:`MessageTracker` objects are designed to be drivable from multiple
|
||||
tracking implementations at once. The idea is that different tracking
|
||||
implementations can cover different parts of the path a stanza takes: one
|
||||
can cover the path to the server (by hooking into the events of a
|
||||
:class:`~.StanzaToken`), the other implementation can use e.g. :xep:`184`
|
||||
to determine delivery at the target and so on.
|
||||
|
||||
Methods and attributes from the "protected" interface are marked by a
|
||||
leading underscore.
|
||||
|
||||
.. autoattribute:: state
|
||||
|
||||
.. autoattribute:: response
|
||||
|
||||
.. autoattribute:: closed
|
||||
|
||||
.. signal:: on_state_changed(new_state, response=None)
|
||||
|
||||
Emits when a new state is entered.
|
||||
|
||||
:param new_state: The new state of the tracker.
|
||||
:type new_state: :class:`~.MessageState` member
|
||||
:param response: A stanza related to the state.
|
||||
:type response: :class:`~.StanzaBase` or :data:`None`
|
||||
|
||||
The is *not* emitted when the tracker is closed.
|
||||
|
||||
.. signal:: on_closed()
|
||||
|
||||
Emits when the tracker is closed.
|
||||
|
||||
.. automethod:: close
|
||||
|
||||
.. automethod:: set_timeout
|
||||
|
||||
"Protected" interface:
|
||||
|
||||
.. automethod:: _set_state
|
||||
|
||||
"""
|
||||
|
||||
on_closed = aioxmpp.callbacks.Signal()
|
||||
on_state_changed = aioxmpp.callbacks.Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._state = MessageState.IN_TRANSIT
|
||||
self._response = None
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
The current state of the tracking. Read-only.
|
||||
"""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def response(self):
|
||||
"""
|
||||
A stanza which is relevant to the current state. For
|
||||
:attr:`.MessageState.ERROR`, this will generally be a
|
||||
:class:`.MessageType.ERROR` stanza. For other states, this is either
|
||||
:data:`None` or another stanza depending on the tracking
|
||||
implementation.
|
||||
"""
|
||||
return self._response
|
||||
|
||||
@property
|
||||
def closed(self):
|
||||
"""
|
||||
Boolean indicator whether the tracker is closed.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:meth:`close` for details.
|
||||
"""
|
||||
return self._closed
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the tracking, clear all references to the tracker and release all
|
||||
tracking-related resources.
|
||||
|
||||
This operation is idempotent. It does not change the :attr:`state`, but
|
||||
:attr:`closed` turns :data:`True`.
|
||||
|
||||
The :meth:`on_closed` event is only fired on the first call to
|
||||
:meth:`close`.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self.on_closed()
|
||||
|
||||
def set_timeout(self, timeout):
|
||||
"""
|
||||
Automatically close the tracker after `timeout` has elapsed.
|
||||
|
||||
:param timeout: The timeout after which the tracker is closed
|
||||
automatically.
|
||||
:type timeout: :class:`numbers.Real` or :class:`datetime.timedelta`
|
||||
|
||||
If the `timeout` is not a :class:`datetime.timedelta` instance, it is
|
||||
assumed to be given as seconds.
|
||||
|
||||
The timeout cannot be cancelled after it has been set. It starts at the
|
||||
very moment :meth:`set_timeout` is called.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if isinstance(timeout, timedelta):
|
||||
timeout = timeout.total_seconds()
|
||||
|
||||
loop.call_later(timeout, self.close)
|
||||
|
||||
# "Protected" Interface
|
||||
|
||||
def _set_state(self, new_state, response=None):
|
||||
"""
|
||||
Set the state of the tracker.
|
||||
|
||||
:param new_state: The new state of the tracker.
|
||||
:type new_state: :class:`~.MessageState` member
|
||||
:param response: A stanza related to the new state.
|
||||
:type response: :class:`~.StanzaBase` or :data:`None`
|
||||
:raise ValueError: if a forbidden state transition is attempted.
|
||||
:raise RuntimeError: if the tracker is closed.
|
||||
|
||||
The state of the tracker is set to the `new_state`. The
|
||||
:attr:`response` is also overridden with the new value, no matter if the
|
||||
new or old value is :data:`None` or not. The :meth:`on_state_changed`
|
||||
event is emitted.
|
||||
|
||||
The following transitions are forbidden and attempting to perform them
|
||||
will raise :class:`ValueError`:
|
||||
|
||||
* any state -> :attr:`~.MessageState.IN_TRANSIT`
|
||||
* :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` ->
|
||||
:attr:`~.MessageState.DELIVERED_TO_SERVER`
|
||||
* :attr:`~.MessageState.SEEN_BY_RECIPIENT` ->
|
||||
:attr:`~.MessageState.DELIVERED_TO_RECIPIENT`
|
||||
* :attr:`~.MessageState.SEEN_BY_RECIPIENT` ->
|
||||
:attr:`~.MessageState.DELIVERED_TO_SERVER`
|
||||
* :attr:`~.MessageState.ABORTED` -> any state
|
||||
* :attr:`~.MessageState.ERROR` -> any state
|
||||
|
||||
If the tracker is already :meth:`close`\\ -d, :class:`RuntimeError` is
|
||||
raised. This check happens *before* a test is made whether the
|
||||
transition is valid.
|
||||
|
||||
This method is part of the "protected" interface.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("message tracker is closed")
|
||||
|
||||
# reject some transitions as documented
|
||||
if (self._state == MessageState.ABORTED or
|
||||
new_state == MessageState.IN_TRANSIT or
|
||||
(self._state == MessageState.ERROR and
|
||||
new_state == MessageState.DELIVERED_TO_SERVER) or
|
||||
(self._state == MessageState.ERROR and
|
||||
new_state == MessageState.ABORTED) or
|
||||
(self._state == MessageState.DELIVERED_TO_RECIPIENT and
|
||||
new_state == MessageState.DELIVERED_TO_SERVER) or
|
||||
(self._state == MessageState.SEEN_BY_RECIPIENT and
|
||||
new_state == MessageState.DELIVERED_TO_SERVER) or
|
||||
(self._state == MessageState.SEEN_BY_RECIPIENT and
|
||||
new_state == MessageState.DELIVERED_TO_RECIPIENT)):
|
||||
raise ValueError(
|
||||
"message tracker transition from {} to {} not allowed".format(
|
||||
self._state,
|
||||
new_state
|
||||
)
|
||||
)
|
||||
|
||||
self._state = new_state
|
||||
self._response = response
|
||||
self.on_state_changed(self._state, self._response)
|
||||
|
||||
|
||||
class BasicTrackingService(aioxmpp.service.Service):
|
||||
"""
|
||||
Error handling and :class:`~.StanzaToken`\\ -based tracking for messages.
|
||||
|
||||
This service provides the most basic tracking of message stanzas. It can be
|
||||
combined with other forms of tracking.
|
||||
|
||||
Specifically, the stanza is tracked using the means of
|
||||
:class:`~.StanzaToken`, that is, until it is acknowledged by the server. In
|
||||
addition, error stanzas in reply to the message are also tracked (but they
|
||||
do not override states occurring after
|
||||
:attr:`~.MessageState.DELIVERED_TO_SERVER`).
|
||||
|
||||
Tracking stanzas:
|
||||
|
||||
.. automethod:: send_tracked
|
||||
|
||||
.. automethod:: attach_tracker
|
||||
"""
|
||||
|
||||
def __init__(self, client, **kwargs):
|
||||
super().__init__(client, **kwargs)
|
||||
self._trackers = {}
|
||||
|
||||
@aioxmpp.service.inbound_message_filter
|
||||
def _inbound_message_filter(self, message):
|
||||
try:
|
||||
if message.type_ != aioxmpp.MessageType.ERROR:
|
||||
return message
|
||||
except AttributeError:
|
||||
return message
|
||||
|
||||
try:
|
||||
key = message.from_.bare(), message.id_
|
||||
except AttributeError:
|
||||
return message
|
||||
|
||||
try:
|
||||
tracker = self._trackers.pop(key)
|
||||
except KeyError:
|
||||
return message
|
||||
|
||||
if tracker.state == MessageState.DELIVERED_TO_RECIPIENT:
|
||||
return
|
||||
if tracker.state == MessageState.SEEN_BY_RECIPIENT:
|
||||
return
|
||||
tracker._set_state(MessageState.ERROR, message)
|
||||
|
||||
def _tracker_closed(self, key):
|
||||
self._trackers.pop(key, None)
|
||||
|
||||
def _stanza_sent(self, tracker, token, fut):
|
||||
# FIXME: look into whether this is correct, and if it is, document why:
|
||||
#
|
||||
# - CancelledError does not lead to ABORTED
|
||||
# - why it makes sense to channel all *other* exceptions into
|
||||
# ABORTED state
|
||||
try:
|
||||
fut.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except: # NOQA: E722
|
||||
next_state = MessageState.ABORTED
|
||||
else:
|
||||
next_state = MessageState.DELIVERED_TO_SERVER
|
||||
try:
|
||||
tracker._set_state(next_state)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def send_tracked(self, stanza, tracker):
|
||||
"""
|
||||
Send a message stanza with tracking.
|
||||
|
||||
:param stanza: Message stanza to send.
|
||||
:type stanza: :class:`aioxmpp.Message`
|
||||
:param tracker: Message tracker to use.
|
||||
:type tracker: :class:`~.MessageTracker`
|
||||
:rtype: :class:`~.StanzaToken`
|
||||
:return: The token used to send the stanza.
|
||||
|
||||
If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is
|
||||
created.
|
||||
|
||||
This configures tracking for the stanza as if by calling
|
||||
:meth:`attach_tracker` with a `token` and sends the stanza through the
|
||||
stream.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:meth:`attach_tracker`
|
||||
can be used if the stanza cannot be sent (e.g. because it is a
|
||||
carbon-copy) or has already been sent.
|
||||
"""
|
||||
token = self.client.enqueue(stanza)
|
||||
self.attach_tracker(stanza, tracker, token)
|
||||
return token
|
||||
|
||||
def attach_tracker(self, stanza, tracker=None, token=None):
|
||||
"""
|
||||
Configure tracking for a stanza without sending it.
|
||||
|
||||
:param stanza: Message stanza to send.
|
||||
:type stanza: :class:`aioxmpp.Message`
|
||||
:param tracker: Message tracker to use.
|
||||
:type tracker: :class:`~.MessageTracker` or :data:`None`
|
||||
:param token: Optional stanza token for more fine-grained tracking.
|
||||
:type token: :class:`~.StanzaToken`
|
||||
:rtype: :class:`~.MessageTracker`
|
||||
:return: The message tracker.
|
||||
|
||||
If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is
|
||||
created.
|
||||
|
||||
If `token` is not :data:`None`, updates to the stanza `token` are
|
||||
reflected in the `tracker`.
|
||||
|
||||
If an error reply is received, the tracker will enter
|
||||
:class:`~.MessageState.ERROR` and the error will be set as
|
||||
:attr:`~.MessageTracker.response`.
|
||||
|
||||
You should use :meth:`send_tracked` if possible. This method however is
|
||||
very useful if you need to track carbon copies of sent messages, as a
|
||||
stanza token is not available here and re-sending the message to obtain
|
||||
one is generally not desirable ☺.
|
||||
"""
|
||||
if tracker is None:
|
||||
tracker = MessageTracker()
|
||||
stanza.autoset_id()
|
||||
key = stanza.to.bare(), stanza.id_
|
||||
self._trackers[key] = tracker
|
||||
tracker.on_closed.connect(
|
||||
functools.partial(self._tracker_closed, key)
|
||||
)
|
||||
if token is not None:
|
||||
token.future.add_done_callback(
|
||||
functools.partial(
|
||||
self._stanza_sent,
|
||||
tracker,
|
||||
token,
|
||||
)
|
||||
)
|
||||
return tracker
|
||||
Reference in New Issue
Block a user