This commit is contained in:
2026-08-02 18:57:40 +02:00
parent 5e5ab8681a
commit 6fa59321c0
5759 changed files with 712133 additions and 66 deletions
@@ -0,0 +1,154 @@
########################################################################
# File name: __init__.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/>.
#
########################################################################
"""
Version information
###################
There are two ways to obtain the imported version of the :mod:`aioxmpp`
package:
.. autodata:: __version__
.. data:: version
Alias of :data:`__version__`.
.. autodata:: version_info
.. _api-aioxmpp-services:
Overview of Services
####################
.. autosummary::
:nosignatures:
aioxmpp.AdHocClient
aioxmpp.AvatarService
aioxmpp.BlockingClient
aioxmpp.BookmarkClient
aioxmpp.CarbonsClient
aioxmpp.DiscoClient
aioxmpp.DiscoServer
aioxmpp.EntityCapsService
aioxmpp.MUCClient
aioxmpp.PingService
aioxmpp.PresenceClient
aioxmpp.PresenceServer
aioxmpp.PEPClient
aioxmpp.RosterClient
aioxmpp.VersionServer
Shorthands
##########
.. function:: make_security_layer
Alias of :func:`aioxmpp.security_layer.make`.
"""
from ._version import version_info, __version__, version # NOQA: F401
#: The imported :mod:`aioxmpp` version as a tuple.
#:
#: The components of the tuple are, in order: `major version`, `minor version`,
#: `patch level`, and `pre-release identifier`.
#:
#: .. seealso::
#:
#: :ref:`api-stability`
version_info = version_info
#: The imported :mod:`aioxmpp` version as a string.
#:
#: The version number is dot-separated; in pre-release or development versions,
#: the version number is followed by a hypen-separated pre-release identifier.
#:
#: .. seealso::
#:
#: :ref:`api-stability`
__version__ = __version__
# XXX: ^ this is a hack to make Sphinx find the docs. We could also be using
# .. data instead of .. autodata, but that has the downside that the actual
# version number isnt printed in the docs (without additional maintenance
# cost).
import asyncio # NOQA
# Adds fallback if asyncio version does not provide an ensure_future function.
if not hasattr(asyncio, "ensure_future"):
asyncio.ensure_future = getattr(asyncio, "async")
from .errors import ( # NOQA
XMPPAuthError,
XMPPCancelError,
XMPPContinueError,
XMPPModifyError,
XMPPWaitError,
ErrorCondition,
)
from .stanza import Presence, IQ, Message # NOQA: F401
from .structs import ( # NOQA: F401
JID,
PresenceShow,
PresenceState,
MessageType,
PresenceType,
IQType,
ErrorType,
jid_escape,
jid_unescape,
)
from .security_layer import make as make_security_layer # NOQA: F401
from .node import Client, PresenceManagedClient # NOQA: F401
# services
from .presence import PresenceClient, PresenceServer # NOQA: F401
from .roster import RosterClient # NOQA: F401
from .disco import DiscoServer, DiscoClient # NOQA: F401
from .entitycaps import EntityCapsService # NOQA: F401
from .muc import MUCClient # NOQA: F401
from .pubsub import PubSubClient # NOQA: F401
from .shim import SHIMService # NOQA: F401
from .adhoc import AdHocClient, AdHocServer # NOQA: F401
from .avatar import AvatarService # NOQA: F401
from .blocking import BlockingClient # NOQA: F401
from .carbons import CarbonsClient # NOQA: F401
from .ping import PingService # NOQA: F401
from .pep import PEPClient # NOQA: F401
from .bookmarks import BookmarkClient # NOQA: F401
from .version import VersionServer # NOQA: F401
from .mdr import DeliveryReceiptsService # NOQA: F401
from . import httpupload # NOQA: F401
def set_strict_mode():
from .stanza import Error
from .stream import StanzaStream
from . import structs
Message.type_.type_.allow_coerce = False
IQ.type_.type_.allow_coerce = False
Error.type_.type_.allow_coerce = False
Presence.type_.type_.allow_coerce = False
StanzaStream._ALLOW_ENUM_COERCION = False
structs._USE_COMPAT_ENUM = False
@@ -0,0 +1,28 @@
########################################################################
# File name: _version.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/>.
#
########################################################################
version_info = (0, 13, 3, None)
__version__ = ".".join(map(str, version_info[:3])) + ("-"+version_info[3] if
version_info[3] else "")
version = __version__
@@ -0,0 +1,87 @@
########################################################################
# File name: __init__.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.adhoc` --- Ad-Hoc Commands support (:xep:`50`)
#############################################################
This subpackage implements support for Ad-Hoc Commands as specified in
:xep:`50`. Both the client and the server side of Ad-Hoc Commands are
supported.
.. versionadded:: 0.8
Client-side
===========
.. currentmodule:: aioxmpp
.. autoclass:: AdHocClient
.. currentmodule:: aioxmpp.adhoc.service
.. autoclass:: ClientSession
Server-side
===========
.. currentmodule:: aioxmpp.adhoc
.. autoclass:: AdHocServer
.. currentmodule:: aioxmpp.adhoc.service
..
.. autoclass:: ServerSession
XSOs
====
.. currentmodule:: aioxmpp.adhoc.xso
.. autoclass:: Command
.. autoclass:: Actions
.. autoclass:: Note
.. currentmodule:: aioxmpp.adhoc
Enumerations
------------
.. autoclass:: CommandStatus
.. autoclass:: ActionType
"""
from .service import ( # NOQA: F401
AdHocClient,
ClientSession,
AdHocServer,
)
from .xso import ( # NOQA: F401
CommandStatus,
ActionType,
)
from . import xso # NOQA: F401
@@ -0,0 +1,601 @@
########################################################################
# File name: service.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/>.
#
########################################################################
import asyncio
# import base64
import collections
import logging
import random
# from datetime import timedelta
import aioxmpp.disco
import aioxmpp.errors
import aioxmpp.disco.xso as disco_xso
import aioxmpp.service
import aioxmpp.structs
from aioxmpp.utils import namespaces
from . import xso as adhoc_xso
_logger = logging.getLogger(__name__)
_rng = random.SystemRandom()
class SessionError(RuntimeError):
pass
class ClientCancelledError(SessionError):
pass
class AdHocClient(aioxmpp.service.Service):
"""
Access other entities :xep:`50` Ad-Hoc commands.
This service provides helpers to conveniently access and execute :xep:`50`
Ad-Hoc commands.
.. automethod:: supports_commands
.. automethod:: get_commands
.. automethod:: get_command_info
.. automethod:: execute
"""
ORDER_AFTER = [aioxmpp.disco.DiscoClient]
async def get_commands(self, peer_jid):
"""
Return the list of commands offered by the peer.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`~aioxmpp.JID`
:rtype: :class:`list` of :class:`~.disco.xso.Item`
:return: List of command items
In the returned list, each :class:`~.disco.xso.Item` represents one
command supported by the peer. The :attr:`~.disco.xso.Item.node`
attribute is the identifier of the command which can be used with
:meth:`get_command_info` and :meth:`execute`.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_items(
peer_jid,
node=namespaces.xep0050_commands,
)
return response.items
async def get_command_info(self, peer_jid, command_name):
"""
Obtain information about a command.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: Node name of the command
:type command_name: :class:`str`
:rtype: :class:`~.disco.xso.InfoQuery`
:return: Service discovery information about the command
Sends a service discovery query to the service discovery node of the
command. The returned object contains information about the command,
such as the namespaces used by its implementation (generally the
:xep:`4` data forms namespace) and possibly localisations of the
commands name.
The `command_name` can be obtained by inspecting the listing from
:meth:`get_commands` or from well-known command names as defined for
example in :xep:`133`.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_info(
peer_jid,
node=command_name,
)
return response
async def supports_commands(self, peer_jid):
"""
Detect whether a peer supports :xep:`50` Ad-Hoc commands.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`aioxmpp.JID`
:rtype: :class:`bool`
:return: True if the peer supports the Ad-Hoc commands protocol, false
otherwise.
Note that the fact that a peer supports the protocol does not imply
that it offers any commands.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_info(
peer_jid,
)
return namespaces.xep0050_commands in response.features
async def execute(self, peer_jid, command_name):
"""
Start execution of a command with a peer.
:param peer_jid: JID of the peer to start the command at.
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: Node name of the command to execute.
:type command_name: :class:`str`
:rtype: :class:`~.adhoc.service.ClientSession`
:return: A started command execution session.
Initialises a client session and starts execution of the command. The
session is returned.
This may raise any exception which may be raised by
:meth:`~.adhoc.service.ClientSession.start`.
"""
session = ClientSession(
self.client.stream,
peer_jid,
command_name,
)
await session.start()
return session
CommandEntry = collections.namedtuple(
"CommandEntry",
[
"name",
"is_allowed",
"handler",
"features",
]
)
class CommandEntry(aioxmpp.disco.StaticNode):
def __init__(self, name, handler, features=set(), is_allowed=None):
super().__init__()
if isinstance(name, str):
self.__name = aioxmpp.structs.LanguageMap({None: name})
else:
self.__name = aioxmpp.structs.LanguageMap(name)
self.__handler = handler
features = set(features) | {namespaces.xep0050_commands}
for feature in features:
self.register_feature(feature)
self.__is_allowed = is_allowed
self.register_identity(
"automation",
"command-node",
names=self.__name
)
@property
def name(self):
return self.__name
@property
def handler(self):
return self.__handler
@property
def is_allowed(self):
return self.__is_allowed
def is_allowed_for(self, *args, **kwargs):
if self.__is_allowed is None:
return True
return self.__is_allowed(*args, **kwargs)
def iter_identities(self, stanza):
if not self.is_allowed_for(stanza.from_):
return iter([])
return super().iter_identities(stanza)
class AdHocServer(aioxmpp.service.Service, aioxmpp.disco.Node):
"""
Support for serving Ad-Hoc commands.
.. .. automethod:: register_stateful_command
.. automethod:: register_stateless_command
.. automethod:: unregister_command
"""
ORDER_AFTER = [aioxmpp.disco.DiscoServer]
disco_node = aioxmpp.disco.mount_as_node(
"http://jabber.org/protocol/commands"
)
disco_feature = aioxmpp.disco.register_feature(
"http://jabber.org/protocol/commands"
)
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self.register_identity(
"automation", "command-list",
)
self._commands = {}
self._disco = self.dependencies[aioxmpp.disco.DiscoServer]
@aioxmpp.service.iq_handler(aioxmpp.IQType.SET,
adhoc_xso.Command)
async def _handle_command(self, stanza):
try:
info = self._commands[stanza.payload.node]
except KeyError:
raise aioxmpp.errors.XMPPCancelError(
aioxmpp.errors.ErrorCondition.ITEM_NOT_FOUND,
text="no such command: {!r}".format(
stanza.payload.node
)
)
if not info.is_allowed_for(stanza.from_):
raise aioxmpp.errors.XMPPCancelError(
aioxmpp.errors.ErrorCondition.FORBIDDEN,
)
return await info.handler(stanza)
def iter_items(self, stanza):
local_jid = self.client.local_jid
languages = [
aioxmpp.structs.LanguageRange.fromstr("en"),
]
if stanza.lang is not None:
languages.insert(0, aioxmpp.structs.LanguageRange.fromstr(
str(stanza.lang)
))
for node, info in self._commands.items():
if not info.is_allowed_for(stanza.from_):
continue
yield disco_xso.Item(
local_jid,
name=info.name.lookup(languages),
node=node,
)
def register_stateless_command(self, node, name, handler, *,
is_allowed=None,
features={namespaces.xep0004_data}):
"""
Register a handler for a stateless command.
:param node: Name of the command (``node`` in the service discovery
list).
:type node: :class:`str`
:param name: Human-readable name of the command
:type name: :class:`str` or :class:`~.LanguageMap`
:param handler: Coroutine function to run to get the response for a
request.
:param is_allowed: A predicate which determines whether the command is
shown and allowed for a given peer.
:type is_allowed: function or :data:`None`
:param features: Set of features to announce for the command
:type features: :class:`set` of :class:`str`
When a request for the command is received, `handler` is invoked. The
semantics of `handler` are the same as for
:meth:`~.StanzaStream.register_iq_request_handler`. It must produce a
valid :class:`~.adhoc.xso.Command` response payload.
If `is_allowed` is not :data:`None`, it is invoked whenever a command
listing is generated and whenever a command request is received. The
:class:`aioxmpp.JID` of the requester is passed as positional argument
to `is_allowed`. If `is_allowed` returns false, the command is not
included in the list and attempts to execute it are rejected with
``<forbidden/>`` without calling `handler`.
If `is_allowed` is :data:`None`, the command is always visible and
allowed.
The `features` are returned on a service discovery info request for the
command node. By default, the :xep:`4` (Data Forms) namespace is
included, but this can be overridden by passing a different set without
that feature to `features`.
"""
info = CommandEntry(
name,
handler,
is_allowed=is_allowed,
features=features,
)
self._commands[node] = info
self._disco.mount_node(
node,
info,
)
def unregister_command(self, node):
"""
Unregister a command previously registered.
:param node: Name of the command (``node`` in the service discovery
list).
:type node: :class:`str`
"""
class ClientSession:
"""
Represent an Ad-Hoc command session on the client side.
:param stream: The stanza stream over which the session is established.
:type stream: :class:`~.StanzaStream`
:param peer_jid: The full JID of the peer to communicate with
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: The command to run
:type command_name: :class:`str`
The constructor does not send any stanza, it merely prepares the internal
state. To start the command itself, use the :class:`ClientSession` object
as context manager or call :meth:`start`.
.. note::
The client session returned by :meth:`.AdHocClient.execute` is already
started.
The `command_name` must be one of the :attr:`~.disco.xso.Item.node` values
as returned by :meth:`.AdHocClient.get_commands`.
.. automethod:: start
.. automethod:: proceed
.. automethod:: close
The following attributes change depending on the stage of execution of the
command:
.. autoattribute:: allowed_actions
.. autoattribute:: first_payload
.. autoattribute:: response
.. autoattribute:: status
"""
def __init__(self, stream, peer_jid, command_name, *, logger=None):
super().__init__()
self._stream = stream
self._peer_jid = peer_jid
self._command_name = command_name
self._logger = logger or _logger
self._status = None
self._response = None
@property
def status(self):
"""
The current status of command execution. This is either :data:`None` or
one of the :class:`~.adhoc.CommandStatus` enumeration values.
Initially, this attribute is :data:`None`. After calls to
:meth:`start`, :meth:`proceed` or :meth:`close`, it takes the value of
the :attr:`~.xso.Command.status` attribute of the response.
"""
if self._response is not None:
return self._response.status
return None
@property
def response(self):
"""
The last :class:`~.xso.Command` received from the peer.
This is initially (and after :meth:`close`) :data:`None`.
"""
return self._response
@property
def first_payload(self):
"""
Shorthand to access :attr:`~.xso.Command.first_payload` of the
:attr:`response`.
This is initially (and after :meth:`close`) :data:`None`.
"""
if self._response is not None:
return self._response.first_payload
return None
@property
def sessionid(self):
"""
Shorthand to access :attr:`~.xso.Command.sessionid` of the
:attr:`response`.
This is initially (and after :meth:`close`) :data:`None`.
"""
if self._response is not None:
return self._response.sessionid
return None
@property
def allowed_actions(self):
"""
Shorthand to access :attr:`~.xso.Actions.allowed_actions` of the
:attr:`response`.
If no response has been received yet or if the response specifies no
set of valid actions, this is the minimal set of allowed actions (
:attr:`~.ActionType.EXECUTE` and :attr:`~.ActionType.CANCEL`).
"""
if self._response is not None and self._response.actions is not None:
return self._response.actions.allowed_actions
return {adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
async def start(self):
"""
Initiate the session by starting to execute the command with the peer.
:return: The :attr:`~.xso.Command.first_payload` of the response
This sends an empty command IQ request with the
:attr:`~.ActionType.EXECUTE` action.
The :attr:`status`, :attr:`response` and related attributes get updated
with the newly received values.
"""
if self._response is not None:
raise RuntimeError("command execution already started")
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=adhoc_xso.Command(self._command_name),
)
self._response = await self._stream.send_iq_and_wait_for_reply(
request,
)
return self._response.first_payload
async def proceed(self, *,
action=adhoc_xso.ActionType.EXECUTE,
payload=None):
"""
Proceed command execution to the next stage.
:param action: Action type for proceeding
:type action: :class:`~.ActionTyp`
:param payload: Payload for the request, or :data:`None`
:return: The :attr:`~.xso.Command.first_payload` of the response
`action` must be one of the actions returned by
:attr:`allowed_actions`. It defaults to :attr:`~.ActionType.EXECUTE`,
which is (alongside with :attr:`~.ActionType.CANCEL`) always allowed.
`payload` may be a sequence of XSOs, a single XSO or :data:`None`. If
it is :data:`None`, the XSOs from the request are re-used. This is
useful if you modify the payload in-place (e.g. via
:attr:`first_payload`). Otherwise, the payload on the request is set to
the `payload` argument; if it is a single XSO, it is wrapped in a
sequence.
The :attr:`status`, :attr:`response` and related attributes get updated
with the newly received values.
"""
if self._response is None:
raise RuntimeError("command execution not started yet")
if action not in self.allowed_actions:
raise ValueError("action {} not allowed in this stage".format(
action
))
cmd = adhoc_xso.Command(
self._command_name,
action=action,
payload=self._response.payload if payload is None else payload,
sessionid=self.sessionid,
)
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=cmd,
)
try:
self._response = await self._stream.send_iq_and_wait_for_reply(
request,
)
except (aioxmpp.errors.XMPPModifyError,
aioxmpp.errors.XMPPCancelError) as exc:
if isinstance(exc.application_defined_condition,
(adhoc_xso.BadSessionID,
adhoc_xso.SessionExpired)):
await self.close()
raise SessionError(exc.text)
if isinstance(exc, aioxmpp.errors.XMPPCancelError):
await self.close()
raise
return self._response.first_payload
async def close(self):
if self._response is None:
return
if self.status != adhoc_xso.CommandStatus.COMPLETED:
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=adhoc_xso.Command(
self._command_name,
sessionid=self.sessionid,
action=adhoc_xso.ActionType.CANCEL,
)
)
try:
await self._stream.send_iq_and_wait_for_reply(
request,
)
except aioxmpp.errors.StanzaError as exc:
# we are cancelling only out of courtesy.
# if something goes wrong here, its barely worth logging
self._logger.debug(
"ignored stanza error during close(): %r",
exc,
)
self._response = None
async def __aenter__(self):
if self._response is None:
await self.start()
return self
async def __aexit__(self, exc_type, exc_value, exc_traceback):
await self.close()
@@ -0,0 +1,228 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import collections.abc
import enum
import aioxmpp.stanza
import aioxmpp.forms
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0050_commands = "http://jabber.org/protocol/commands"
class NoteType(enum.Enum):
INFO = "info"
WARN = "warn"
ERROR = "error"
class ActionType(enum.Enum):
NEXT = "next"
EXECUTE = "execute"
PREV = "prev"
CANCEL = "cancel"
COMPLETE = "complete"
class CommandStatus(enum.Enum):
"""
Describes the status a command execution is in.
.. attribute:: EXECUTING
The command is being executed.
.. attribute:: COMPLETED
The command has been completed.
.. attribute:: CANCELED
The command has been canceled.
"""
EXECUTING = "executing"
COMPLETED = "completed"
CANCELED = "canceled"
class Note(xso.XSO):
TAG = (namespaces.xep0050_commands, "note")
body = xso.Text(
default=None,
)
type_ = xso.Attr(
"type",
type_=xso.EnumCDataType(
NoteType,
),
default=NoteType.INFO,
)
def __init__(self, type_, body):
super().__init__()
self.type_ = type_
self.body = body
class Actions(xso.XSO):
TAG = (namespaces.xep0050_commands, "actions")
next_is_allowed = xso.ChildFlag(
(namespaces.xep0050_commands, "next"),
)
prev_is_allowed = xso.ChildFlag(
(namespaces.xep0050_commands, "prev"),
)
complete_is_allowed = xso.ChildFlag(
(namespaces.xep0050_commands, "complete"),
)
execute = xso.Attr(
"execute",
type_=xso.EnumCDataType(ActionType),
validator=xso.RestrictToSet({
ActionType.NEXT,
ActionType.PREV,
ActionType.COMPLETE,
}),
default=None,
)
@property
def allowed_actions(self):
result = [ActionType.EXECUTE, ActionType.CANCEL]
if self.prev_is_allowed:
result.append(ActionType.PREV)
if self.next_is_allowed:
result.append(ActionType.NEXT)
if self.complete_is_allowed:
result.append(ActionType.COMPLETE)
return frozenset(result)
@allowed_actions.setter
def allowed_actions(self, values):
values = frozenset(values)
if ActionType.EXECUTE not in values:
raise ValueError("EXECUTE must always be allowed")
if ActionType.CANCEL not in values:
raise ValueError("CANCEL must always be allowed")
self.prev_is_allowed = ActionType.PREV in values
self.next_is_allowed = ActionType.NEXT in values
self.complete_is_allowed = ActionType.COMPLETE in values
@aioxmpp.IQ.as_payload_class
class Command(xso.XSO):
TAG = (namespaces.xep0050_commands, "command")
actions = xso.Child([Actions])
notes = xso.ChildList([Note])
action = xso.Attr(
"action",
type_=xso.EnumCDataType(ActionType),
default=ActionType.EXECUTE,
)
status = xso.Attr(
"status",
type_=xso.EnumCDataType(CommandStatus),
default=None,
)
sessionid = xso.Attr(
"sessionid",
default=None,
)
node = xso.Attr(
"node",
)
payload = xso.ChildList([
aioxmpp.forms.Data,
])
def __init__(self, node, *,
action=ActionType.EXECUTE,
status=None,
sessionid=None,
payload=[],
notes=[],
actions=None):
super().__init__()
self.node = node
self.action = action
self.status = status
self.sessionid = sessionid
if not isinstance(payload, collections.abc.Iterable):
self.payload[:] = [payload]
else:
self.payload[:] = payload
self.notes[:] = notes
self.actions = actions
@property
def first_payload(self):
try:
return self.payload[0]
except IndexError:
return
MalformedAction = aioxmpp.stanza.make_application_error(
"MalformedAction",
(namespaces.xep0050_commands, "malformed-action"),
)
BadAction = aioxmpp.stanza.make_application_error(
"BadAction",
(namespaces.xep0050_commands, "bad-action"),
)
BadLocale = aioxmpp.stanza.make_application_error(
"BadLocale",
(namespaces.xep0050_commands, "bad-locale"),
)
BadPayload = aioxmpp.stanza.make_application_error(
"BadPayload",
(namespaces.xep0050_commands, "bad-payload"),
)
BadSessionID = aioxmpp.stanza.make_application_error(
"BadSessionID",
(namespaces.xep0050_commands, "bad-sessionid"),
)
SessionExpired = aioxmpp.stanza.make_application_error(
"SessionExpired",
(namespaces.xep0050_commands, "session-expired"),
)
@@ -0,0 +1,91 @@
########################################################################
# File name: __init__.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.avatar` --- User avatar support (:xep:`0084`)
############################################################
This module provides support for publishing and retrieving user
avatars as per :xep:`User Avatar <84>`.
Services
========
The following service is provided by this subpackage:
.. currentmodule:: aioxmpp
.. autosummary::
AvatarService
The detailed documentation of the classes follows:
.. autoclass:: AvatarService()
.. currentmodule:: aioxmpp.avatar
Data Representation
===================
The following class is used to describe the possible locations of an
avatar image:
.. autoclass:: AvatarSet
.. module:: aioxmpp.avatar.service
.. currentmodule:: aioxmpp.avatar.service
.. autoclass:: AbstractAvatarDescriptor()
.. currentmodule:: aioxmpp.avatar
Helpers
=======
.. autofunction:: normalize_id
How to work with avatar descriptors
===================================
.. currentmodule:: aioxmpp.avatar.service
One you have retrieved the avatar descriptor list, the correct way to
handle it in the application:
1. Select the avatar you prefer based on the
:attr:`~AbstractAvatarDescriptor.can_get_image_bytes_via_xmpp`, and
metadata information (:attr:`~AbstractAvatarDescriptor.mime_type`,
:attr:`~AbstractAvatarDescriptor.width`,
:attr:`~AbstractAvatarDescriptor.height`,
:attr:`~AbstractAvatarDescriptor.nbytes`). If you cache avatar
images it might be a good choice to choose an avatar image you
already have cached based on
:attr:`~AbstractAvatarDescriptor.normalized_id`.
2. If :attr:`~AbstractAvatarDescriptor.can_get_image_bytes_via_xmpp`
is true, try to retrieve the image by
:attr:`~AbstractAvatarDescriptor.get_image_bytes()`; if it is false
try to retrieve the object at the URL
:attr:`~AbstractAvatarDescriptor.url`.
"""
from .service import (AvatarSet, AvatarService, # NOQA: F401
normalize_id)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import aioxmpp.xso as xso
import aioxmpp.pubsub.xso as pubsub_xso
from aioxmpp.utils import namespaces
from ..stanza import Presence
namespaces.xep0084_data = "urn:xmpp:avatar:data"
namespaces.xep0084_metadata = "urn:xmpp:avatar:metadata"
namespaces.xep0153 = "vcard-temp:x:update"
class VCardTempUpdate(xso.XSO):
"""
The vcard update notify element as per :xep:`0153`
"""
TAG = (namespaces.xep0153, "x")
def __init__(self, photo=None):
self.photo = photo
photo = xso.ChildText((namespaces.xep0153, "photo"),
type_=xso.String(),
default=None)
Presence.xep0153_x = xso.Child([VCardTempUpdate])
@pubsub_xso.as_payload_class
class Data(xso.XSO):
"""
A data node, as used to publish and receive the avatar image data
as image/png.
.. attribute:: data
The binary image data.
"""
TAG = (namespaces.xep0084_data, "data")
data = xso.Text(type_=xso.Base64Binary())
def __init__(self, image_data):
self.data = image_data
class Info(xso.XSO):
"""
An info node specifying avatar metadata for a specific MIME type.
.. attribute:: id_
The SHA1 of the avatar image data.
.. attribute:: mime_type
The MIME type of the avatar image.
.. attribute:: nbytes
The size of the image data in bytes.
.. attribute:: width
The width of the image in pixels. Defaults to :data:`None`.
.. attribute:: height
The height of the image in pixels. Defaults to :data:`None`.
.. attribute:: url
The URL of the image. Defaults to :data:`None`.
"""
TAG = (namespaces.xep0084_metadata, "info")
id_ = xso.Attr(tag="id", type_=xso.String())
mime_type = xso.Attr(tag="type", type_=xso.String())
nbytes = xso.Attr(tag="bytes", type_=xso.Integer())
width = xso.Attr(tag="width", type_=xso.Integer(), default=None)
height = xso.Attr(tag="height", type_=xso.Integer(), default=None)
url = xso.Attr(tag="url", type_=xso.String(), default=None)
def __init__(self, id_, mime_type, nbytes, width=None,
height=None, url=None):
self.id_ = id_
self.mime_type = mime_type
self.nbytes = nbytes
self.width = width
self.height = height
self.url = url
class Pointer(xso.XSO):
"""
A pointer metadata node. The contents are implementation defined.
The following attributes may be present (they default to
:data:`None`):
.. attribute:: id_
The SHA1 of the avatar image data.
.. attribute:: mime_type
The MIME type of the avatar image.
.. attribute:: nbytes
The size of the image data in bytes.
.. attribute:: width
The width of the image in pixels.
.. attribute:: height
The height of the image in pixels.
"""
TAG = (namespaces.xep0084_metadata, "pointer")
# according to the XEP those MAY occur if their values are known
id_ = xso.Attr(tag="id", type_=xso.String(), default=None)
mime_type = xso.Attr(tag="type", type_=xso.String(), default=None)
nbytes = xso.Attr(tag="bytes", type_=xso.Integer(), default=None)
width = xso.Attr(tag="width", type_=xso.Integer(), default=None)
height = xso.Attr(tag="height", type_=xso.Integer(), default=None)
registered_payload = xso.Child([])
unregistered_payload = xso.Collector()
@classmethod
def as_payload_class(mycls, cls):
"""
Register the given class `cls` as possible payload for a
:class:`Pointer`.
Return the class, to allow this to be used as decorator.
"""
mycls.register_child(
Pointer.registered_payload,
cls
)
return cls
def __init__(self, payload, id_, mime_type, nbytes, width=None,
height=None, url=None):
self.registered_payload = payload
self.id_ = id_
self.mime_type = mime_type
self.nbytes = nbytes
self.width = width
self.height = height
@pubsub_xso.as_payload_class
class Metadata(xso.XSO):
"""
A metadata node which used to publish and reveice avatar image
metadata.
.. attribute:: info
A map from the MIME type to the corresponding :class:`Info` XSO.
.. attribute:: pointer
A list of the :class:`Pointer` children.
"""
TAG = (namespaces.xep0084_metadata, "metadata")
info = xso.ChildMap([Info], key=lambda x: x.mime_type)
pointer = xso.ChildList([Pointer])
def iter_info_nodes(self):
"""
Iterate over all :class:`Info` children.
"""
info_map = self.info
for mime_type in info_map:
for metadata_info_node in info_map[mime_type]:
yield metadata_info_node
@@ -0,0 +1,311 @@
########################################################################
# File name: __init__.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/>.
#
########################################################################
import asyncio
import collections
import contextlib
import importlib
import math
import functools
import time
import os
from nose.plugins import Plugin
def scaleinfo(n, significant_digits=None):
if abs(n) == 0:
order_of_magnitude = 0
else:
order_of_magnitude = math.floor(math.log(n, 10))
prefix_level = math.floor(order_of_magnitude / 3)
prefix_level = min(6, max(-6, prefix_level))
PREFIXES = {
-6: "a",
-5: "f",
-4: "p",
-3: "n",
-2: "μ",
-1: "m",
0: "",
1: "k",
2: "M",
3: "G",
4: "T",
5: "P",
6: "E",
}
prefix_magnitude = prefix_level*3
scale = 10**prefix_magnitude
n /= scale
if significant_digits is not None:
digits = order_of_magnitude - prefix_magnitude + 1
round_to = significant_digits - digits
rhs = max(round_to, 0)
lhs = max(math.floor(math.log(n, 10))+1, 1)
return n, round_to, (lhs, rhs), PREFIXES[prefix_level]
else:
s = str(n)
lhs = s.index(".")
rhs = len(s)-s.index(".")-1
return n, 3, (lhs, rhs), PREFIXES[prefix_level]
def autoscale_number(n, significant_digits=None):
n, round_to, _, prefix = scaleinfo(n, significant_digits)
n = round(n, round_to)
fmt_num = "{{:.{}f}}".format(max(round_to, 0))
fmt = "{}{{prefix}}".format(fmt_num)
return fmt.format(
n,
prefix=prefix
)
class Accumulator:
def __init__(self):
super().__init__()
self.items = []
self.total = 0
self.unit = None
def add(self, value):
self.items.append(value)
self.total += value
def set_unit(self, unit):
if self.unit is not None and self.unit != unit:
raise RuntimeError(
"attempt to change unit of accumulator"
)
self.unit = unit
@property
def average(self):
return self.total / self.total_runs
@property
def max(self):
return max(self.items)
@property
def min(self):
return min(self.items)
@property
def stddev(self):
return math.sqrt(self.variance)
@property
def variance(self):
avg = self.average
accum = 0
for value in self.items:
accum += (value - avg)**2
return accum / len(self.items)
@property
def total_runs(self):
return len(self.items)
def infodict(self):
return {
"nsamples": self.total_runs,
"avg": self.average,
"total": self.total,
"stddev": self.stddev,
"min": self.min,
"max": self.max,
}
@property
def structured_avg(self):
avg = self.average
stddev = self.stddev
if stddev == 0:
digits = None
else:
digits = math.ceil(math.log(avg / stddev, 10))
return scaleinfo(avg, digits) + (self.unit,)
def __str__(self):
avg = self.average
stddev = self.stddev
if stddev == 0:
digits = None
else:
digits = math.ceil(math.log(avg / stddev, 10))
return "nsamples: {}; average: {}{}".format(
self.total_runs,
autoscale_number(avg, digits),
self.unit or ""
)
class Timer:
start = None
end = None
@property
def elapsed(self):
if self.end is None or self.start is None:
raise RuntimeError("timer is still running")
return self.end - self.start
@contextlib.contextmanager
def timed(key=None):
timer = Timer()
t0 = time.monotonic()
try:
yield timer
finally:
t1 = time.monotonic()
timer.start = t0
timer.end = t1
if key is not None:
accum = _registry[key]
accum.add(timer.elapsed)
accum.set_unit("s")
def record(key, value, unit):
accum = _registry[key]
accum.set_unit(unit)
accum.add(value)
def times(n, pass_iteration=False):
if n < 1:
raise ValueError(
"times decorator needs at least one iteration"
)
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
base_kwargs = kwargs
for i in range(n-1):
if pass_iteration:
kwargs = dict(base_kwargs)
kwargs["iteration"] = i
f(*args, **kwargs)
if pass_iteration:
kwargs = dict(base_kwargs)
kwargs["iteration"] = n-1
return f(*args, **kwargs)
return wrapper
return decorator
class BenchmarkPlugin(Plugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def options(self, options, env=os.environ):
options.add_option(
"--benchmark-report",
dest="aioxmpp_bench_report",
default=None,
metavar="FILE",
help="File to save the report to",
)
options.add_option(
"--benchmark-eventloop",
dest="aioxmpp_eventloop",
default=None,
metavar="CLASS",
help="Event loop policy class to use",
)
def configure(self, options, conf):
self.enabled = True
self.report_filename = options.aioxmpp_bench_report
if options.aioxmpp_eventloop is not None:
module_name, cls_name = options.aioxmpp_eventloop.rsplit(".", 1)
module = importlib.import_module(module_name)
cls = getattr(module, cls_name)()
asyncio.set_event_loop_policy(cls)
asyncio.set_event_loop(asyncio.new_event_loop())
def report(self, stream):
data = {}
table = []
for key, info in sorted(_registry.items(), key=lambda x: x[0]):
if not info.total_runs:
continue
table.append(
(
".".join(key[:2]),
"/".join(key[2:]),
info.total_runs,
info.structured_avg,
),
)
data[key] = info.infodict()
table.sort()
c12len = max(len(c1)+len(c2)+2 for c1, c2, *_ in table)
c12fmt = "{{:<{}s}}".format(c12len)
c3len = max(math.floor(math.log10(v)) + 1
for _, _, v, *_ in table)
c3fmt = "{{:>{}d}}".format(c3len)
c4lhs = max(lhs for _, _, _, (_, _, (lhs, _), _, _) in table)
c4rhs = max(rhs for _, _, _, (_, _, (_, rhs), _, _) in table)
for c1, c2, c3, (v, round_to, (lhs, rhs), prefix, unit) in table:
c4numberfmt = "{{:{}.{}f}}".format(
lhs+rhs+1,
rhs
)
if rhs == 0:
lhs += 1
c4num = "".join([
" "*(c4lhs-lhs),
c4numberfmt.format(v),
"." if rhs == 0 else "",
" "*(c4rhs-rhs)
])
print(
c12fmt.format("{} {}".format(c1, c2)),
c3fmt.format(c3),
"{}{}{}".format(
c4num,
prefix or "",
unit,
),
sep=" ",
file=stream
)
if self.report_filename is not None:
with open(self.report_filename, "w") as f:
f.write(repr(data))
_registry = collections.defaultdict(Accumulator)
@@ -0,0 +1,25 @@
########################################################################
# File name: __main__.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/>.
#
########################################################################
import nose
from aioxmpp.benchtest import BenchmarkPlugin
nose.main(addplugins=[BenchmarkPlugin()])
@@ -0,0 +1,38 @@
########################################################################
# File name: __init__.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.blocking` --- Blocking Command support (:xep:`0191`)
###################################################################
This subpackage provides client side support for :xep:`0191`.
The public interface of this package consists of a single
:class:`~aioxmpp.Service`:
.. currentmodule:: aioxmpp
.. autoclass:: BlockingClient
.. currentmodule:: aioxmpp.blocking
"""
from .service import BlockingClient # NOQA: F401
@@ -0,0 +1,236 @@
########################################################################
# File name: service.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/>.
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.callbacks as callbacks
import aioxmpp.service as service
from aioxmpp.utils import namespaces
from . import xso as blocking_xso
class BlockingClient(service.Service):
"""
A :class:`~aioxmpp.service.Service` implementing :xep:`Blocking
Command <191>`.
This service maintains the list of blocked JIDs and allows
manipulating the blocklist.
Attribute:
.. autoattribute:: blocklist
Signals:
.. signal:: on_initial_blocklist_received(blocklist)
Fires when the initial blocklist was received from the server.
:param blocklist: the initial blocklist
:type blocklist: :class:`~collections.abc.Set` of :class:`~aioxmpp.JID`
.. signal:: on_jids_blocked(blocked_jids)
Fires when additional JIDs are blocked.
:param blocked_jids: the newly blocked JIDs
:type blocked_jids: :class:`~collections.abc.Set`
of :class:`~aioxmpp.JID`
.. signal:: on_jids_blocked(blocked_jids)
Fires when JIDs are unblocked.
:param unblocked_jids: the now unblocked JIDs
:type unblocked_jids: :class:`~collections.abc.Set`
of :class:`~aioxmpp.JID`
Coroutine methods:
.. automethod:: block_jids
.. automethod:: unblock_jids
.. automethod:: unblock_all
"""
ORDER_AFTER = [aioxmpp.DiscoClient]
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._blocklist = None
self._lock = asyncio.Lock()
self._disco = self.dependencies[aioxmpp.DiscoClient]
on_jids_blocked = callbacks.Signal()
on_jids_unblocked = callbacks.Signal()
on_initial_blocklist_received = callbacks.Signal()
async def _check_for_blocking(self):
server_info = await self._disco.query_info(
self.client.local_jid.replace(
resource=None,
localpart=None,
)
)
if namespaces.xep0191 not in server_info.features:
self._blocklist = None
raise RuntimeError("server does not support blocklists!")
@service.depsignal(aioxmpp.Client, "before_stream_established")
async def _get_initial_blocklist(self):
try:
await self._check_for_blocking()
except RuntimeError:
self.logger.info(
"server does not support block lists, skipping initial fetch"
)
return True
if self._blocklist is None:
async with self._lock:
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
payload=blocking_xso.BlockList(),
)
result = await self.client.send(iq)
self._blocklist = frozenset(result.items)
self.on_initial_blocklist_received(self._blocklist)
return True
@property
def blocklist(self):
"""
:class:`~collections.abc.Set` of JIDs blocked by the account.
"""
return self._blocklist
async def block_jids(self, jids_to_block):
"""
Add the JIDs in the sequence `jids_to_block` to the client's
blocklist.
"""
await self._check_for_blocking()
if not jids_to_block:
return
cmd = blocking_xso.BlockCommand(jids_to_block)
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=cmd,
)
await self.client.send(iq)
async def unblock_jids(self, jids_to_unblock):
"""
Remove the JIDs in the sequence `jids_to_block` from the
client's blocklist.
"""
await self._check_for_blocking()
if not jids_to_unblock:
return
cmd = blocking_xso.UnblockCommand(jids_to_unblock)
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=cmd,
)
await self.client.send(iq)
async def unblock_all(self):
"""
Unblock all JIDs currently blocked.
"""
await self._check_for_blocking()
cmd = blocking_xso.UnblockCommand()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=cmd,
)
await self.client.send(iq)
@service.iq_handler(aioxmpp.IQType.SET, blocking_xso.BlockCommand)
async def handle_block_push(self, block_command):
diff = ()
async with self._lock:
if self._blocklist is None:
# this means the stream was destroyed while we were waiting for
# the lock/while the handler was enqueued for scheduling, or
# the server is buggy and sends pushes before we fetched the
# blocklist
return
if (block_command.from_ is None or
block_command.from_ == self.client.local_jid.bare() or
# WORKAROUND: ejabberd#2287
block_command.from_ == self.client.local_jid):
diff = frozenset(block_command.payload.items)
self._blocklist |= diff
else:
self.logger.debug(
"received block push from unauthorized JID: %s",
block_command.from_,
)
if diff:
self.on_jids_blocked(diff)
@service.iq_handler(aioxmpp.IQType.SET, blocking_xso.UnblockCommand)
async def handle_unblock_push(self, unblock_command):
diff = ()
async with self._lock:
if self._blocklist is None:
# this means the stream was destroyed while we were waiting for
# the lock/while the handler was enqueued for scheduling, or
# the server is buggy and sends pushes before we fetched the
# blocklist
return
if (unblock_command.from_ is None or
unblock_command.from_ == self.client.local_jid.bare() or
# WORKAROUND: ejabberd#2287
unblock_command.from_ == self.client.local_jid):
if not unblock_command.payload.items:
diff = frozenset(self._blocklist)
self._blocklist = frozenset()
else:
diff = frozenset(unblock_command.payload.items)
self._blocklist -= diff
else:
self.logger.debug(
"received unblock push from unauthorized JID: %s",
unblock_command.from_,
)
if diff:
self.on_jids_unblocked(diff)
@service.depsignal(aioxmpp.stream.StanzaStream,
"on_stream_destroyed")
def handle_stream_destroyed(self, reason):
self._blocklist = None
@@ -0,0 +1,113 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import aioxmpp
import aioxmpp.xso
from aioxmpp.utils import namespaces
namespaces.xep0191 = "urn:xmpp:blocking"
# this XSO represents a single block list item.
class BlockItem(aioxmpp.xso.XSO):
# define the tag we are matching for
# tags consist of an XML namespace URI and an XML element
TAG = (namespaces.xep0191, "item")
# bind the ``jid`` python attribute to refer to the ``jid`` XML attribute.
# in addition, automatic conversion between actual JID objects and XML
# character data is requested by specifying the `type_` argument as
# xso.JID() object.
jid = aioxmpp.xso.Attr(
"jid",
type_=aioxmpp.xso.JID()
)
# we now declare a custom type to convert between JID objects and BlockItem
# instances.
# we can use this custom type together with xso.ChildValueList to access the
# list of <item xmlns="urn:xmpp:blocking" /> elements like a normal python list
# of JIDs.
class BlockItemType(aioxmpp.xso.AbstractElementType):
# unpack converts from the "raw" XSO to the
# "rich" python representation, in this case a JID object
# think of unpack like of a high-level struct.unpack: we convert
# wire-format (XML trees) to python values
def unpack(self, item):
return item.jid
# pack is the reverse operation of unpack
def pack(self, jid):
item = BlockItem()
item.jid = jid
return item
# we have to tell the XSO framework what XSO types are supported by this
# element type
def get_xso_types(self):
return [BlockItem]
# the decorator tells the IQ stanza class that this is a valid payload; that is
# required to be able to *receive* payloads of this type (sending works without
# that decorator, but is not recommended)
@aioxmpp.stanza.IQ.as_payload_class
class BlockList(aioxmpp.xso.XSO):
TAG = (namespaces.xep0191, "blocklist")
# this does not get an __init__ method, since the client never
# creates a BlockList with entries.
# xso.ChildValueList uses an AbstractElementType (like the one we defined
# above) to convert between child XSO instances and other python objects.
# it is accessed like a normal list, but when parsing/serialising, the
# elements are converted to XML structures using the given type.
items = aioxmpp.xso.ChildValueList(
BlockItemType()
)
@aioxmpp.stanza.IQ.as_payload_class
class BlockCommand(aioxmpp.xso.XSO):
TAG = (namespaces.xep0191, "block")
def __init__(self, jids_to_block=None):
if jids_to_block is not None:
self.items[:] = jids_to_block
items = aioxmpp.xso.ChildValueList(
BlockItemType()
)
@aioxmpp.stanza.IQ.as_payload_class
class UnblockCommand(aioxmpp.xso.XSO):
TAG = (namespaces.xep0191, "unblock")
def __init__(self, jids_to_block=None):
if jids_to_block is not None:
self.items[:] = jids_to_block
items = aioxmpp.xso.ChildValueList(
BlockItemType()
)
@@ -0,0 +1,76 @@
########################################################################
# File name: __init__.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.bookmarks` Bookmark support (:xep:`0048`)
##########################################################
This module provides support for storing and retrieving bookmarks on
the server as per :xep:`Bookmarks <48>`.
Service
=======
.. currentmodule:: aioxmpp
.. autoclass:: BookmarkClient
.. currentmodule:: aioxmpp.bookmarks
XSOs
====
All bookmark types must adhere to the following ABC:
.. autoclass:: Bookmark
The following XSOs are used to represent an manipulate bookmark lists.
.. autoclass:: Conference
.. autoclass:: URL
To register custom bookmark classes use:
.. autofunction:: as_bookmark_class
The following is used internally as the XSO container for bookmarks.
.. autoclass:: Storage
Notes on usage
==============
.. currentmodule:: aioxmpp
It is highly recommended to interact with the bookmark client via the
provided signals and the get-modify-set methods
:meth:`~BookmarkClient.add_bookmark`,
:meth:`~BookmarkClient.discard_bookmark` and
:meth:`~BookmarkClient.update_bookmark`. Using
:meth:`~BookmarkClient.set_bookmarks` directly is error prone and
might cause data loss due to race conditions.
"""
from .xso import (Storage, Bookmark, Conference, URL, # NOQA: F401
as_bookmark_class)
from .service import BookmarkClient # NOQA: F401
@@ -0,0 +1,470 @@
########################################################################
# File name: service.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/>.
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.callbacks as callbacks
import aioxmpp.service as service
import aioxmpp.private_xml as private_xml
from . import xso as bookmark_xso
# TODO: use private storage in pubsub where available.
# TODO: sync bookmarks between pubsub and private xml storage
# TODO: do we need merge-capabilities to reconcile the bookmarks
# from different sources (local bookmark storage, pubsub, private xml
# storage)
class BookmarkClient(service.Service):
"""
Supports retrieval and storage of bookmarks on the server.
It currently only supports :xep:`Private XML Storage <49>` as
backend.
There is the general rule *never* to modify the bookmark instances
retrieved from this class (either by :meth:`get_bookmarks` or as
an argument to one of the signals). If you need to modify a bookmark
for use with :meth:`update_bookmark` use :func:`copy.copy` to create
a copy.
.. automethod:: sync
.. automethod:: get_bookmarks
.. automethod:: set_bookmarks
The following methods change the bookmark list in a get-modify-set
pattern, to mitigate the danger of race conditions and should be
used in most circumstances:
.. automethod:: add_bookmark
.. automethod:: discard_bookmark
.. automethod:: update_bookmark
The following signals are provided that allow tracking the changes to
the bookmark list:
.. signal:: on_bookmark_added(added_bookmark)
Fires when a new bookmark is added.
.. signal:: on_bookmark_removed(removed_bookmark)
Fires when a bookmark is removed.
.. signal:: on_bookmark_changed(old_bookmark, new_bookmark)
Fires when a bookmark is changed.
.. note:: A heuristic is used to determine the change of bookmarks
and the reported changes may not directly reflect the
used methods, but it will always be possible to
construct the list of bookmarks from the events. For
example, when using :meth:`update_bookmark` to change
the JID of a :class:`Conference` bookmark a removed and
a added signal will fire.
.. note:: The bookmark protocol is prone to race conditions if
several clients access it concurrently. Be careful to
use a get-modify-set pattern or the provided highlevel
interface.
.. note:: Some other clients extend the bookmark format. For now
those extensions are silently dropped by our XSOs, and
therefore are lost, when changing the bookmarks with
aioxmpp. This is considered a bug to be fixed in the future.
"""
ORDER_AFTER = [
private_xml.PrivateXMLService,
]
on_bookmark_added = callbacks.Signal()
on_bookmark_removed = callbacks.Signal()
on_bookmark_changed = callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._private_xml = self.dependencies[private_xml.PrivateXMLService]
self._bookmark_cache = []
self._lock = asyncio.Lock()
@service.depsignal(aioxmpp.Client, "on_stream_established", defer=True)
async def _stream_established(self):
await self.sync()
async def _get_bookmarks(self):
"""
Get the stored bookmarks from the server.
:returns: a list of bookmarks
"""
res = await self._private_xml.get_private_xml(
bookmark_xso.Storage()
)
return res.registered_payload.bookmarks
async def _set_bookmarks(self, bookmarks):
"""
Set the bookmarks stored on the server.
"""
storage = bookmark_xso.Storage()
storage.bookmarks[:] = bookmarks
await self._private_xml.set_private_xml(storage)
def _diff_emit_update(self, new_bookmarks):
"""
Diff the bookmark cache and the new bookmark state, emit signals as
needed and set the bookmark cache to the new data.
"""
self.logger.debug("diffing %s, %s", self._bookmark_cache,
new_bookmarks)
def subdivide(level, old, new):
"""
Subdivide the bookmarks according to the data item
``bookmark.secondary[level]`` and emit the appropriate
events.
"""
if len(old) == len(new) == 1:
old_entry = old.pop()
new_entry = new.pop()
if old_entry == new_entry:
pass
else:
self.on_bookmark_changed(old_entry, new_entry)
return ([], [])
elif len(old) == 0:
return ([], new)
elif len(new) == 0:
return (old, [])
else:
try:
groups = {}
for entry in old:
group = groups.setdefault(
entry.secondary[level],
([], [])
)
group[0].append(entry)
for entry in new:
group = groups.setdefault(
entry.secondary[level],
([], [])
)
group[1].append(entry)
except IndexError:
# the classification is exhausted, this means
# all entries in this bin are equal by the
# definition of bookmark equivalence!
common = min(len(old), len(new))
assert old[:common] == new[:common]
return (old[common:], new[common:])
old_unhandled, new_unhandled = [], []
for old, new in groups.values():
unhandled = subdivide(level+1, old, new)
old_unhandled += unhandled[0]
new_unhandled += unhandled[1]
# match up unhandleds as changes as early as possible
i = -1
for i, (old_entry, new_entry) in enumerate(
zip(old_unhandled, new_unhandled)):
self.logger.debug("changed %s -> %s", old_entry, new_entry)
self.on_bookmark_changed(old_entry, new_entry)
i += 1
return old_unhandled[i:], new_unhandled[i:]
# group the bookmarks into groups whose elements may transform
# among one another by on_bookmark_changed events. This information
# is given by the type of the bookmark and the .primary property
changable_groups = {}
for item in self._bookmark_cache:
group = changable_groups.setdefault(
(type(item), item.primary),
([], [])
)
group[0].append(item)
for item in new_bookmarks:
group = changable_groups.setdefault(
(type(item), item.primary),
([], [])
)
group[1].append(item)
for old, new in changable_groups.values():
# the first branches are fast paths which should catch
# most cases especially all cases where each bare jid of
# a conference bookmark or each url of an url bookmark is
# only used in one bookmark
if len(old) == len(new) == 1:
old_entry = old.pop()
new_entry = new.pop()
if old_entry == new_entry:
# the bookmark is unchanged, do not emit an event
pass
else:
self.logger.debug("changed %s -> %s", old_entry, new_entry)
self.on_bookmark_changed(old_entry, new_entry)
elif len(new) == 0:
for removed in old:
self.logger.debug("removed %s", removed)
self.on_bookmark_removed(removed)
elif len(old) == 0:
for added in new:
self.logger.debug("added %s", added)
self.on_bookmark_added(added)
else:
old, new = subdivide(0, old, new)
assert len(old) == 0 or len(new) == 0
for removed in old:
self.logger.debug("removed %s", removed)
self.on_bookmark_removed(removed)
for added in new:
self.logger.debug("added %s", added)
self.on_bookmark_added(added)
self._bookmark_cache = new_bookmarks
async def get_bookmarks(self):
"""
Get the stored bookmarks from the server. Causes signals to be
fired to reflect the changes.
:returns: a list of bookmarks
"""
async with self._lock:
bookmarks = await self._get_bookmarks()
self._diff_emit_update(bookmarks)
return bookmarks
async def set_bookmarks(self, bookmarks):
"""
Store the sequence of bookmarks `bookmarks`.
Causes signals to be fired to reflect the changes.
.. note:: This should normally not be used. It does not
mitigate the race condition between clients
concurrently modifying the bookmarks and may lead to
data loss. Use :meth:`add_bookmark`,
:meth:`discard_bookmark` and :meth:`update_bookmark`
instead. This method still has use-cases (modifying
the bookmarklist at large, e.g. by syncing the
remote store with local data).
"""
async with self._lock:
await self._set_bookmarks(bookmarks)
self._diff_emit_update(bookmarks)
async def sync(self):
"""
Sync the bookmarks between the local representation and the
server.
This must be called periodically to assure that the signals
are fired.
"""
await self.get_bookmarks()
async def add_bookmark(self, new_bookmark, *, max_retries=3):
"""
Add a bookmark and check whether it was successfully added to the
bookmark list. Already existent bookmarks are not added twice.
:param new_bookmark: the bookmark to add
:type new_bookmark: an instance of :class:`~bookmark_xso.Bookmark`
:param max_retries: the number of retries if setting the bookmark
fails
:type max_retries: :class:`int`
:raises RuntimeError: if the bookmark is not in the bookmark list
after `max_retries` retries.
After setting the bookmark it is checked, whether the bookmark
is in the online storage, if it is not it is tried again at most
`max_retries` times to add the bookmark. A :class:`RuntimeError`
is raised if the bookmark could not be added successfully after
`max_retries`.
"""
async with self._lock:
bookmarks = await self._get_bookmarks()
try:
modified_bookmarks = list(bookmarks)
if new_bookmark not in bookmarks:
modified_bookmarks.append(new_bookmark)
await self._set_bookmarks(modified_bookmarks)
retries = 0
bookmarks = await self._get_bookmarks()
while retries < max_retries:
if new_bookmark in bookmarks:
break
modified_bookmarks = list(bookmarks)
modified_bookmarks.append(new_bookmark)
await self._set_bookmarks(modified_bookmarks)
bookmarks = await self._get_bookmarks()
retries += 1
if new_bookmark not in bookmarks:
raise RuntimeError("Could not add bookmark")
finally:
self._diff_emit_update(bookmarks)
async def discard_bookmark(self, bookmark_to_remove, *, max_retries=3):
"""
Remove a bookmark and check it has been removed.
:param bookmark_to_remove: the bookmark to remove
:type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass.
:param max_retries: the number of retries of removing the bookmark
fails.
:type max_retries: :class:`int`
:raises RuntimeError: if the bookmark is not removed from
bookmark list after `max_retries`
retries.
If there are multiple occurrences of the same bookmark exactly
one is removed.
This does nothing if the bookmarks does not match an existing
bookmark according to bookmark-equality.
After setting the bookmark it is checked, whether the bookmark
is removed in the online storage, if it is not it is tried
again at most `max_retries` times to remove the bookmark. A
:class:`RuntimeError` is raised if the bookmark could not be
removed successfully after `max_retries`.
"""
async with self._lock:
bookmarks = await self._get_bookmarks()
occurrences = bookmarks.count(bookmark_to_remove)
try:
if not occurrences:
return
modified_bookmarks = list(bookmarks)
modified_bookmarks.remove(bookmark_to_remove)
await self._set_bookmarks(modified_bookmarks)
retries = 0
bookmarks = await self._get_bookmarks()
new_occurences = bookmarks.count(bookmark_to_remove)
while retries < max_retries:
if new_occurences < occurrences:
break
modified_bookmarks = list(bookmarks)
modified_bookmarks.remove(bookmark_to_remove)
await self._set_bookmarks(modified_bookmarks)
bookmarks = await self._get_bookmarks()
new_occurences = bookmarks.count(bookmark_to_remove)
retries += 1
if new_occurences >= occurrences:
raise RuntimeError("Could not remove bookmark")
finally:
self._diff_emit_update(bookmarks)
async def update_bookmark(self, old, new, *, max_retries=3):
"""
Update a bookmark and check it was successful.
The bookmark matches an existing bookmark `old` according to
bookmark equalitiy and replaces it by `new`. The bookmark
`new` is added if no bookmark matching `old` exists.
:param old: the bookmark to replace
:type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass.
:param new: the replacement bookmark
:type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass.
:param max_retries: the number of retries of removing the bookmark
fails.
:type max_retries: :class:`int`
:raises RuntimeError: if the bookmark is not in the bookmark list
after `max_retries` retries.
After replacing the bookmark it is checked, whether the
bookmark `new` is in the online storage, if it is not it is
tried again at most `max_retries` times to replace the
bookmark. A :class:`RuntimeError` is raised if the bookmark
could not be replaced successfully after `max_retries`.
.. note:: Do not modify a bookmark retrieved from the signals
or from :meth:`get_bookmarks` to obtain the bookmark
`new`, this will lead to data corruption as they are
passed by reference. Instead use :func:`copy.copy`
and modify the copy.
"""
def replace_bookmark(bookmarks, old, new):
modified_bookmarks = list(bookmarks)
try:
i = bookmarks.index(old)
modified_bookmarks[i] = new
except ValueError:
modified_bookmarks.append(new)
return modified_bookmarks
async with self._lock:
bookmarks = await self._get_bookmarks()
try:
await self._set_bookmarks(
replace_bookmark(bookmarks, old, new)
)
retries = 0
bookmarks = await self._get_bookmarks()
while retries < max_retries:
if new in bookmarks:
break
await self._set_bookmarks(
replace_bookmark(bookmarks, old, new)
)
bookmarks = await self._get_bookmarks()
retries += 1
if new not in bookmarks:
raise RuntimeError("Cold not update bookmark")
finally:
self._diff_emit_update(bookmarks)
@@ -0,0 +1,262 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
from abc import abstractproperty
import aioxmpp.private_xml as private_xml
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0048 = "storage:bookmarks"
class Bookmark(xso.XSO):
"""
A bookmark XSO abstract base class.
Every XSO class registered as child of :class:`Storage` must be
a :class:`Bookmark` subclass.
Bookmarks must provide the following interface:
.. autoattribute:: primary
.. autoattribute:: secondary
.. autoattribute:: name
Equality is defined in terms of those properties:
.. automethod:: __eq__
It is highly recommended not to redefine :meth:`__eq__` in a
subclass, if you do so make sure that the following axiom
relating :meth:`__eq__`, :attr:`primary` and :attr:`secondary`
holds::
(type(a) == type(b) and
a.primary == b.primary and
a.secondary == b.secondary)
if and only if::
a == b
Otherwise the generation of bookmark change signals is not
guaranteed to be correct.
"""
def __eq__(self, other):
"""
Compare for equality by value and type.
The value of a bookmark must be fully determined by the values
of the :attr:`primary` and :attr:`secondary` properties.
This is used for generating the bookmark list change signals
and for the get-modify-set methods.
"""
return (type(self) == type(other) and
self.primary == other.primary and
self.secondary == other.secondary)
@abstractproperty
def primary(self):
"""
Return the primary category of the bookmark.
The internal structure of the category is opaque to the code
using it; only equality and hashing must be provided and
operate by value. It is recommended that this be either a
single datum (e.g. a string or JID) or a tuple of data items.
Together with the type and :attr:`secondary` this must *fully*
determine the value of the bookmark.
This is used in the computation of the change
signals. Bookmarks with different type or :attr:`primary`
keys cannot be identified as changed from/to one another.
"""
raise NotImplementedError # pragma: no cover
@abstractproperty
def secondary(self):
"""
Return the tuple of secondary categories of the bookmark.
Together with the type and :attr:`primary` they must *fully*
determine the value of the bookmark.
This is used in the computation of the change signals. The
categories in the tuple are ordered in decreasing precedence,
when calculating which bookmarks have changed the ones which
mismatch in the category with the lowest precedence are
grouped together.
The length of the tuple must be the same for all bookmarks of
a type.
"""
raise NotImplementedError # pragma: no cover
@abstractproperty
def name(self):
"""
The human-readable label or description of the bookmark.
"""
raise NotImplementedError # pragma: no cover
class Conference(Bookmark):
"""
An bookmark for a groupchat.
.. attribute:: name
The name of the bookmark.
.. attribute:: jid
The jid under which the groupchat is accessible.
.. attribute:: autojoin
Whether to join automatically, when the client starts.
.. attribute:: nick
The nick to use in the groupchat.
.. attribute:: password
The password used to access the groupchat.
"""
TAG = (namespaces.xep0048, "conference")
autojoin = xso.Attr(tag="autojoin", type_=xso.Bool(), default=False)
jid = xso.Attr(tag="jid", type_=xso.JID())
name = xso.Attr(tag="name", type_=xso.String(), default=None)
nick = xso.ChildText(
(namespaces.xep0048, "nick"),
default=None
)
password = xso.ChildText(
(namespaces.xep0048, "password"),
default=None
)
def __init__(self, name, jid, *, autojoin=False, nick=None, password=None):
self.autojoin = autojoin
self.jid = jid
self.name = name
self.nick = nick
self.password = password
def __repr__(self):
return "Conference({!r}, {!r}, autojoin={!r}, " \
"nick={!r}, password{!r})".\
format(self.name, self.jid, self.autojoin, self.nick,
self.password)
@property
def primary(self):
return self.jid
@property
def secondary(self):
return (self.name, self.nick, self.password, self.autojoin)
class URL(Bookmark):
"""
An URL bookmark.
.. attribute:: name
The name of the bookmark.
.. attribute:: url
The URL the bookmark saves.
"""
TAG = (namespaces.xep0048, "url")
name = xso.Attr(tag="name", type_=xso.String(), default=None)
# XXX: we might want to use a URL type once we have one
url = xso.Attr(tag="url", type_=xso.String())
def __init__(self, name, url):
self.name = name
self.url = url
def __repr__(self):
return "URL({!r}, {!r})".format(self.name, self.url)
@property
def primary(self):
return self.url
@property
def secondary(self):
return (self.name,)
@private_xml.Query.as_payload_class
class Storage(xso.XSO):
"""
The container for storing bookmarks.
.. attribute:: bookmarks
A :class:`~xso.XSOList` of bookmarks.
"""
TAG = (namespaces.xep0048, "storage")
bookmarks = xso.ChildList([URL, Conference])
def as_bookmark_class(xso_class):
"""
Decorator to register `xso_class` as a custom bookmark class.
This is necessary to store and retrieve such bookmarks.
The registered class must be a subclass of the abstract base class
:class:`Bookmark`.
:raises TypeError: if `xso_class` is not a subclass of :class:`Bookmark`.
"""
if not issubclass(xso_class, Bookmark):
raise TypeError(
"Classes registered as bookmark types must be Bookmark subclasses"
)
Storage.register_child(
Storage.bookmarks,
xso_class
)
return xso_class
@@ -0,0 +1,179 @@
########################################################################
# File name: cache.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.cache` --- Utilities for implementing caches
###########################################################
.. versionadded:: 0.9
This module was added in version 0.9.
.. autoclass:: LRUDict
"""
import collections.abc
class Node:
__slots__ = ("prev", "next_", "key", "value")
def _init_linked_list():
root = Node()
root.prev = root
root.next_ = root
root.key = None
root.value = None
return root
def _remove_node(node):
node.next_.prev = node.prev
node.prev.next_ = node.next_
return node
def _insert_node(before, new_node):
new_node.next_ = before.next_
new_node.next_.prev = new_node
new_node.prev = before
before.next_ = new_node
def _length(node):
# this is used only for testing
cur = node.next_
i = 0
while cur is not node:
i += 1
cur = cur.next_
return i
def _has_consistent_links(node, node_dict=None):
# this is used only for testing
cur = node.next_
if cur.prev is not node:
return False
while cur is not node:
if node_dict is not None and node_dict[cur.key] is not cur:
return False
if cur is not cur.next_.prev:
return False
cur = cur.next_
return True
class LRUDict(collections.abc.MutableMapping):
"""
Size-restricted dictionary with Least Recently Used expiry policy.
.. versionadded:: 0.9
The :class:`LRUDict` supports normal dictionary-style access and implements
:class:`collections.abc.MutableMapping`.
When the :attr:`maxsize` is exceeded, as many entries as needed to get
below the :attr:`maxsize` are removed from the dict. Least recently used
entries are purged first. Setting an entry does *not* count as use!
.. autoattribute:: maxsize
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.__links = {}
self.__root = _init_linked_list()
self.__maxsize = 1
def _test_consistency(self):
"""
This method is only used for testing to assert that the operations
leave the LRUDict in a valid state.
"""
return (_length(self.__root) == len(self.__links) and
_has_consistent_links(self.__root, self.__links))
def _purge(self):
if self.__maxsize is None:
return
while len(self.__links) > self.__maxsize:
link = _remove_node(self.__root.prev)
del self.__links[link.key]
@property
def maxsize(self):
"""
Maximum size of the cache. Changing this property purges overhanging
entries immediately.
If set to :data:`None`, no limit on the number of entries is imposed.
Do **not** use a limit of :data:`None` for data where the `key` is
under control of a remote entity.
Use cases for :data:`None` are those where you only need the explicit
expiry feature, but not the LRU feature.
"""
return self.__maxsize
@maxsize.setter
def maxsize(self, value):
if value is not None and value <= 0:
raise ValueError("maxsize must be positive integer or None")
self.__maxsize = value
self._purge()
def __len__(self):
return len(self.__links)
def __iter__(self):
return iter(self.__links)
def __setitem__(self, key, value):
try:
self.__links[key].value = value
except KeyError:
link = Node()
link.key = key
link.value = value
self.__links[key] = link
_insert_node(self.__root, link)
self._purge()
def __getitem__(self, key):
link = self.__links[key]
_remove_node(link)
_insert_node(self.__root, link)
return link.value
def __delitem__(self, key):
link = self.__links.pop(key)
_remove_node(link)
def clear(self):
self.__links.clear()
self.__root = _init_linked_list()
@@ -0,0 +1,895 @@
########################################################################
# File name: callbacks.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.callbacks` -- Synchronous and asynchronous callbacks
###################################################################
This module provides facilities for objects to provide signals to which other
objects can connect.
Descriptor vs. ad-hoc
=====================
Descriptors can be used as class attributes and will create ad-hoc signals
dynamically for each instance. They are the most commonly used:
.. code-block:: python
class Emitter:
on_event = callbacks.Signal()
def handler():
pass
emitter1 = Emitter()
emitter2 = Emitter()
emitter1.on_event.connect(handler)
emitter1.on_event() # calls `handler`
emitter2.on_event() # does not call `handler`
# the actual signals are distinct
assert emitter1.on_event is not emitter2.on_event
Ad-hoc signals are useful for testing and are the type of which the actual
fields are.
Signal overview
===============
.. autosummary::
Signal
SyncSignal
AdHocSignal
SyncAdHocSignal
Utilities
---------
.. autofunction:: first_signal
Signal descriptors
------------------
These descriptors can be used on classes to have attributes which are signals:
.. autoclass:: Signal
.. autoclass:: SyncSignal
Signal implementations (ad-hoc signals)
---------------------------------------
Whenever accessing an attribute using the :class:`Signal` or
:class:`SyncSignal` descriptors, an object of one of the following classes is
returned. This is where the behaviour of the signals is specified.
.. autoclass:: AdHocSignal
.. autoclass:: SyncAdHocSignal
Filters
=======
.. autoclass:: Filter
"""
import abc
import asyncio
import collections
import contextlib
import functools
import logging
import types
import weakref
logger = logging.getLogger(__name__)
def log_spawned(logger, fut):
try:
result = fut.result()
except asyncio.CancelledError:
logger.debug("spawned task was cancelled")
except: # NOQA
logger.warning("spawned task raised exception", exc_info=True)
else:
if result is not None:
logger.info("value returned by spawned task was ignored: %r",
result)
class TagListener:
def __init__(self, ondata, onerror=None):
self._ondata = ondata
self._onerror = onerror
def data(self, data):
return self._ondata(data)
def error(self, exc):
if self._onerror is not None:
return self._onerror(exc)
def is_valid(self):
return True
class AsyncTagListener(TagListener):
def __init__(self, ondata, onerror=None, *, loop=None):
super().__init__(ondata, onerror)
self._loop = loop or asyncio.get_event_loop()
def data(self, data):
self._loop.call_soon(self._ondata, data)
def error(self, exc):
if self._onerror is not None:
self._loop.call_soon(self._onerror, exc)
class OneshotTagListener(TagListener):
def __init__(self, ondata, onerror=None, **kwargs):
super().__init__(ondata, onerror=onerror, **kwargs)
self._cancelled = False
def data(self, data):
super().data(data)
return True
def error(self, exc):
super().error(exc)
return True
def cancel(self):
self._cancelled = True
def is_valid(self):
return not self._cancelled and super().is_valid()
class OneshotAsyncTagListener(OneshotTagListener, AsyncTagListener):
pass
class FutureListener:
def __init__(self, fut):
self.fut = fut
def data(self, data):
try:
self.fut.set_result(data)
except asyncio.InvalidStateError:
pass
return True
def error(self, exc):
try:
self.fut.set_exception(exc)
except asyncio.InvalidStateError:
pass
return True
def is_valid(self):
return not self.fut.done()
class TagDispatcher:
def __init__(self):
self._listeners = {}
def add_callback(self, tag, fn):
return self.add_listener(tag, TagListener(fn))
def add_callback_async(self, tag, fn, *, loop=None):
return self.add_listener(
tag,
AsyncTagListener(fn, loop=loop)
)
def add_future(self, tag, fut):
return self.add_listener(
tag,
FutureListener(fut)
)
def add_listener(self, tag, listener):
try:
existing = self._listeners[tag]
if not existing.is_valid():
raise KeyError()
except KeyError:
self._listeners[tag] = listener
else:
raise ValueError("only one listener is allowed per tag")
def unicast(self, tag, data):
cb = self._listeners[tag]
if not cb.is_valid():
del self._listeners[tag]
self._listeners[tag]
if cb.data(data):
del self._listeners[tag]
def unicast_error(self, tag, exc):
cb = self._listeners[tag]
if not cb.is_valid():
del self._listeners[tag]
self._listeners[tag]
if cb.error(exc):
del self._listeners[tag]
def remove_listener(self, tag):
del self._listeners[tag]
def broadcast_error(self, exc):
for tag, listener in list(self._listeners.items()):
if listener.is_valid() and listener.error(exc):
del self._listeners[tag]
def close_all(self, exc):
self.broadcast_error(exc)
self._listeners.clear()
class AbstractAdHocSignal:
def __init__(self):
super().__init__()
self._connections = collections.OrderedDict()
self.logger = logger
def _connect(self, wrapper):
token = object()
self._connections[token] = wrapper
return token
def disconnect(self, token):
"""
Disconnect the connection identified by `token`. This never raises,
even if an invalid `token` is passed.
"""
try:
del self._connections[token]
except KeyError:
pass
class AdHocSignal(AbstractAdHocSignal):
"""
An ad-hoc signal is a single emitter. This is where callables are connected
to, using the :meth:`connect` method of the :class:`AdHocSignal`.
.. automethod:: fire
.. automethod:: connect
.. automethod:: context_connect
.. automethod:: future
.. attribute:: logger
This may be a :class:`logging.Logger` instance to allow the signal to
log errors and debug events to a specific logger instead of the default
logger (``aioxmpp.callbacks``).
This attribute must not be :data:`None`, and it is initialised to the
default logger on creation of the :class:`AdHocSignal`.
The different ways callables can be connected to an ad-hoc signal are shown
below:
.. attribute:: STRONG
Connections using this mode keep a strong reference to the callable. The
callable is called directly, thus blocking the emission of the signal.
.. attribute:: WEAK
Connections using this mode keep a weak reference to the callable. The
callable is executed directly, thus blocking the emission of the signal.
If the weak reference is dead, it is automatically removed from the
signals connection list. If the callable is a bound method,
:class:`weakref.WeakMethod` is used automatically.
For both :attr:`STRONG` and :attr:`WEAK` holds: if the callable returns a
true value, it is disconnected from the signal.
.. classmethod:: ASYNC_WITH_LOOP(loop)
This mode requires an :mod:`asyncio` event loop as argument. When the
signal is emitted, the callable is not called directly. Instead, it is
enqueued for calling with the event loop using
:meth:`asyncio.BaseEventLoop.call_soon`. If :data:`None` is passed as
`loop`, the loop is obtained from :func:`asyncio.get_event_loop` at
connect time.
A strong reference is held to the callable.
Connections using this mode are never removed automatically from the
signals connection list. You have to use :meth:`disconnect` explicitly.
.. attribute:: AUTO_FUTURE
Instead of a callable, a :class:`asyncio.Future` must be passed when
using this mode.
This mode can only be used for signals which send at most one
positional argument. If no argument is sent, the
:meth:`~asyncio.Future.set_result` method is called with :data:`None`.
If one argument is sent and it is an instance of :class:`Exception`, it
is passed to :meth:`~asyncio.Future.set_exception`. Otherwise, if one
argument is sent, it is passed to
:meth:`~asyncio.Future.set_exception`.
In any case, the future is removed after the next emission of the
signal.
.. classmethod:: SPAWN_WITH_LOOP(loop)
This mode requires an :mod:`asyncio` event loop as argument and a
coroutine to be passed to :meth:`connect`. If :data:`None` is passed as
`loop`, the loop is obtained from :func:`asyncio.get_event_loop` at
connect time.
When the signal is emitted, the coroutine is spawned using
:func:`asyncio.ensure_future` in the given `loop`, with the arguments
passed to the signal.
A strong reference is held to the coroutine.
Connections using this mode are never removed automatically from the
signals connection list. You have to use :meth:`disconnect` explicitly.
If the spawned coroutine returns with an exception or a non-:data:`None`
return value, a message is logged, with the following log levels:
* Return with non-:data:`None` value: :data:`logging.INFO`
* Raises :class:`asyncio.CancelledError`: :data:`logging.DEBUG`
* Raises any other exception: :data:`logging.WARNING`
.. versionadded:: 0.6
.. automethod:: disconnect
"""
@classmethod
def STRONG(cls, f):
if not hasattr(f, "__call__"):
raise TypeError("must be callable, got {!r}".format(f))
return functools.partial(cls._strong_wrapper, f)
@classmethod
def ASYNC_WITH_LOOP(cls, loop):
if loop is None:
loop = asyncio.get_event_loop()
def create_wrapper(f):
if not hasattr(f, "__call__"):
raise TypeError("must be callable, got {!r}".format(f))
return functools.partial(cls._async_wrapper,
f,
loop)
return create_wrapper
@classmethod
def WEAK(cls, f):
if not hasattr(f, "__call__"):
raise TypeError("must be callable, got {!r}".format(f))
if isinstance(f, types.MethodType):
ref = weakref.WeakMethod(f)
else:
ref = weakref.ref(f)
return functools.partial(cls._weakref_wrapper, ref)
@classmethod
def AUTO_FUTURE(cls, f):
def future_wrapper(args, kwargs):
if len(args) > 0:
try:
arg, = args
except ValueError:
raise TypeError("too many arguments") from None
else:
arg = None
if f.done():
return
if isinstance(arg, Exception):
f.set_exception(arg)
else:
f.set_result(arg)
return future_wrapper
@classmethod
def SPAWN_WITH_LOOP(cls, loop):
loop = asyncio.get_event_loop() if loop is None else loop
def spawn(f):
if not asyncio.iscoroutinefunction(f):
raise TypeError("must be coroutine, got {!r}".format(f))
def wrapper(args, kwargs):
task = asyncio.ensure_future(f(*args, **kwargs), loop=loop)
task.add_done_callback(
functools.partial(
log_spawned,
logger,
)
)
return True
return wrapper
return spawn
@staticmethod
def _async_wrapper(f, loop, args, kwargs):
if kwargs:
functools.partial(f, *args, **kwargs)
loop.call_soon(f, *args)
return True
@staticmethod
def _weakref_wrapper(fref, args, kwargs):
f = fref()
if f is None:
return False
return not f(*args, **kwargs)
@staticmethod
def _strong_wrapper(f, args, kwargs):
return not f(*args, **kwargs)
def connect(self, f, mode=None):
"""
Connect an object `f` to the signal. The type the object needs to have
depends on `mode`, but usually it needs to be a callable.
:meth:`connect` returns an opaque token which can be used with
:meth:`disconnect` to disconnect the object from the signal.
The default value for `mode` is :attr:`STRONG`. Any decorator can be
used as argument for `mode` and it is applied to `f`. The result is
stored internally and is what will be called when the signal is being
emitted.
If the result of `mode` returns a false value during emission, the
connection is removed.
.. note::
The return values required by the callable returned by `mode` and
the one required by a callable passed to `f` using the predefined
modes are complementary!
A callable `f` needs to return true to be removed from the
connections, while a callable returned by the `mode` decorator needs
to return false.
Existing modes are listed below.
"""
mode = mode or self.STRONG
self.logger.debug("connecting %r with mode %r", f, mode)
return self._connect(mode(f))
def context_connect(self, f, mode=None):
"""
This returns a *context manager*. When entering the context, `f` is
connected to the :class:`AdHocSignal` using `mode`. When leaving the
context (no matter whether with or without exception), the connection
is disconnected.
.. seealso::
The returned object is an instance of
:class:`SignalConnectionContext`.
"""
return SignalConnectionContext(self, f, mode=mode)
def fire(self, *args, **kwargs):
"""
Emit the signal, calling all connected objects in-line with the given
arguments and in the order they were registered.
:class:`AdHocSignal` provides full isolation with respect to
exceptions. If a connected listener raises an exception, the other
listeners are executed as normal, but the raising listener is removed
from the signal. The exception is logged to :attr:`logger` and *not*
re-raised, so that the caller of the signal is also not affected.
Instead of calling :meth:`fire` explicitly, the ad-hoc signal object
itself can be called, too.
"""
for token, wrapper in list(self._connections.items()):
try:
keep = wrapper(args, kwargs)
except Exception:
self.logger.exception("listener attached to signal raised")
keep = False
if not keep:
del self._connections[token]
def future(self):
"""
Return a :class:`asyncio.Future` which has been :meth:`connect`\\ -ed
using :attr:`AUTO_FUTURE`.
The token returned by :meth:`connect` is not returned; to remove the
future from the signal, just cancel it.
"""
fut = asyncio.Future()
self.connect(fut, self.AUTO_FUTURE)
return fut
__call__ = fire
class SyncAdHocSignal(AbstractAdHocSignal):
"""
A synchronous ad-hoc signal is like :class:`AdHocSignal`, but for
coroutines instead of ordinary callables.
.. automethod:: connect
.. automethod:: context_connect
.. automethod:: fire
.. automethod:: disconnect
"""
def connect(self, coro):
"""
The coroutine `coro` is connected to the signal. The coroutine must
return a true value, unless it wants to be disconnected from the
signal.
.. note::
This is different from the return value convention with
:attr:`AdHocSignal.STRONG` and :attr:`AdHocSignal.WEAK`.
:meth:`connect` returns a token which can be used with
:meth:`disconnect` to disconnect the coroutine.
"""
self.logger.debug("connecting %r", coro)
return self._connect(coro)
def context_connect(self, coro):
"""
This returns a *context manager*. When entering the context, `coro` is
connected to the :class:`SyncAdHocSignal`. When leaving the context (no
matter whether with or without exception), the connection is
disconnected.
.. seealso::
The returned object is an instance of
:class:`SignalConnectionContext`.
"""
return SignalConnectionContext(self, coro)
async def fire(self, *args, **kwargs):
"""
Emit the signal, calling all coroutines in-line with the given
arguments and in the order they were registered.
This is obviously a coroutine.
Instead of calling :meth:`fire` explicitly, the ad-hoc signal object
itself can be called, too.
"""
for token, coro in list(self._connections.items()):
keep = await coro(*args, **kwargs)
if not keep:
del self._connections[token]
__call__ = fire
class SignalConnectionContext:
def __init__(self, signal, *args, **kwargs):
self._signal = signal
self._args = args
self._kwargs = kwargs
def __enter__(self):
try:
token = self._signal.connect(*self._args, **self._kwargs)
finally:
del self._args
del self._kwargs
self._token = token
return token
def __exit__(self, exc_type, exc_value, traceback):
self._signal.disconnect(self._token)
return False
class AbstractSignal(metaclass=abc.ABCMeta):
def __init__(self, *, doc=None):
super().__init__()
self.__doc__ = doc
self._instances = weakref.WeakKeyDictionary()
@abc.abstractclassmethod
def make_adhoc_signal(cls):
pass
def __get__(self, instance, owner):
if instance is None:
return self
try:
return self._instances[instance]
except KeyError:
new = self.make_adhoc_signal()
self._instances[instance] = new
return new
def __set__(self, instance, value):
raise AttributeError("cannot override Signal attribute")
def __delete__(self, instance):
raise AttributeError("cannot override Signal attribute")
class Signal(AbstractSignal):
"""
A descriptor which returns per-instance :class:`AdHocSignal` objects on
attribute access.
Example use:
.. code-block:: python
class Foo:
on_event = Signal()
f = Foo()
assert isinstance(f.on_event, AdHocSignal)
assert f.on_event is f.on_event
assert Foo().on_event is not f.on_event
"""
@classmethod
def make_adhoc_signal(cls):
return AdHocSignal()
class SyncSignal(AbstractSignal):
"""
A descriptor which returns per-instance :class:`SyncAdHocSignal` objects on
attribute access.
Example use:
.. code-block:: python
class Foo:
on_event = SyncSignal()
f = Foo()
assert isinstance(f.on_event, SyncAdHocSignal)
assert f.on_event is f.on_event
assert Foo().on_event is not f.on_event
"""
@classmethod
def make_adhoc_signal(cls):
return SyncAdHocSignal()
class Filter:
"""
A filter chain for arbitrary data.
This is used for example in :class:`~.stream.StanzaStream` to allow
services and applications to filter inbound and outbound stanzas.
Each function registered with the filter receives at least one argument.
This argument is the object which is to be filtered. The function must
return the object, a replacement or :data:`None`. If :data:`None` is
returned, the filter chain aborts and further functions are not called.
Otherwise, the next function is called with the result of the previous
function until the filter chain is complete.
Other arguments passed to :meth:`filter` are passed unmodified to each
function called; only the first argument is subject to filtering.
.. versionchanged:: 0.9
This class was formerly available at :class:`aioxmpp.stream.Filter`.
.. automethod:: register
.. automethod:: filter
.. automethod:: unregister
.. automethod:: context_register(func[, order])
"""
class Token:
def __str__(self):
return "<{}.{} 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
id(self))
def __init__(self):
super().__init__()
self._filter_order = []
def register(self, func, order):
"""
Add a function to the filter chain.
:param func: A callable which is to be added to the filter chain.
:param order: An object indicating the ordering of the function
relative to the others.
:return: Token representing the registration.
Register the function `func` as a filter into the chain. `order` must
be a value which is used as a sorting key to order the functions
registered in the chain.
The type of `order` depends on the use of the filter, as does the
number of arguments and keyword arguments which `func` must accept.
This will generally be documented at the place where the
:class:`Filter` is used.
Functions with the same order are sorted in the order of their
addition, with the function which was added earliest first.
Remember that all values passed to `order` which are registered at the
same time in the same :class:`Filter` need to be totally orderable with
respect to each other.
The returned token can be used to :meth:`unregister` a filter.
"""
token = self.Token()
self._filter_order.append((order, token, func))
self._filter_order.sort(key=lambda x: x[0])
return token
def filter(self, obj, *args, **kwargs):
"""
Filter the given object through the filter chain.
:param obj: The object to filter
:param args: Additional arguments to pass to each filter function.
:param kwargs: Additional keyword arguments to pass to each filter
function.
:return: The filtered object or :data:`None`
See the documentation of :class:`Filter` on how filtering operates.
Returns the object returned by the last function in the filter chain or
:data:`None` if any function returned :data:`None`.
"""
for _, _, func in self._filter_order:
obj = func(obj, *args, **kwargs)
if obj is None:
return None
return obj
def unregister(self, token_to_remove):
"""
Unregister a filter function.
:param token_to_remove: The token as returned by :meth:`register`.
Unregister a function from the filter chain using the token returned by
:meth:`register`.
"""
for i, (_, token, _) in enumerate(self._filter_order):
if token == token_to_remove:
break
else:
raise ValueError("unregistered token: {!r}".format(
token_to_remove))
del self._filter_order[i]
@contextlib.contextmanager
def context_register(self, func, *args):
"""
:term:`Context manager <context manager>` which temporarily registers a
filter function.
:param func: The filter function to register.
:param order: The sorting key for the filter function.
:rtype: :term:`context manager`
:return: Context manager which temporarily registers the filter
function.
If :meth:`register` does not require `order` because it has been
overridden in a subclass, the `order` argument can be omitted here,
too.
.. versionadded:: 0.9
"""
token = self.register(func, *args)
try:
yield
finally:
self.unregister(token)
def first_signal(*signals):
"""
Connect to multiple signals and wait for the first to emit.
:param signals: Signals to connect to.
:type signals: :class:`AdHocSignal`
:return: An awaitable for the first signal to emit.
The awaitable returns the first argument passed to the signal. If the first
argument is an exception, the exception is re-raised from the awaitable.
A common use-case is a situation where a class exposes a "on_finished" type
signal and an "on_failure" type signal. :func:`first_signal` can be used
to combine those nicely::
# e.g. a aioxmpp.im.conversation.AbstractConversation
conversation = ...
await first_signal(
# emits without arguments when the conversation is successfully
# entered
conversation.on_enter,
# emits with an exception when entering the conversation fails
conversation.on_failure,
)
# await first_signal(...) will either raise an exception (failed) or
# return None (success)
.. warning::
Only works with signals which emit with zero or one argument. Signals
which emit with more than one argument or with keyword arguments are
silently ignored! (Thus, if only such signals are connected, the
future will never complete.)
(This is a side-effect of the implementation of
:meth:`AdHocSignal.AUTO_FUTURE`).
.. note::
Does not work with coroutine signals (:class:`SyncAdHocSignal`).
"""
fut = asyncio.Future()
for signal in signals:
signal.connect(fut, signal.AUTO_FUTURE)
return fut
@@ -0,0 +1,66 @@
########################################################################
# File name: __init__.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.carbons` -- Message Carbons (:xep:`280`)
#######################################################
Message Carbons is an XMPP extension which allows an entity to receive copies
of inbound and outbound messages received and sent by other resources of the
same account. It is specified in :xep:`280`. The goal of this feature is to
allow users to have multiple devices which all have a consistent view on the
messages sent and received.
This subpackage provides basic support for Message Carbons. It allows enabling
and disabling the feature at the server side.
Service
=======
.. currentmodule:: aioxmpp
.. autoclass:: CarbonsClient
.. currentmodule:: aioxmpp.carbons
.. currentmodule:: aioxmpp.carbons.xso
.. module:: aioxmpp.carbons.xso
XSOs
====
.. attribute:: aioxmpp.Message.xep0280_sent
On a Carbon message, this holds the :class:`~.carbons.xso.Sent` XSO which in
turn holds the carbonated stanza.
.. attribute:: aioxmpp.Message.xep0280_received
On a Carbon message, this holds the :class:`~.carbons.xso.Received` XSO
which in turn holds the carbonated stanza.
.. autoclass:: Received
.. autoclass:: Sent
"""
from .service import CarbonsClient # NOQA: F401
@@ -0,0 +1,106 @@
########################################################################
# File name: service.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/>.
#
########################################################################
import asyncio
import aioxmpp.service
from aioxmpp.utils import namespaces
from . import xso as carbons_xso
class CarbonsClient(aioxmpp.service.Service):
"""
Provide an interface to enable and disable Message Carbons on the server
side.
.. note::
This service deliberately does not provide a way to actually obtain sent
or received carbonated messages.
The common way for a service to do this would be a stanza filter (see
:class:`aioxmpp.stream.StanzaStream`); however, in general the use and
further distribution of carbonated messages highly depends on the
application: it does, for example, not make sense to simply unwrap
carbonated messages.
.. automethod:: enable
.. automethod:: disable
"""
ORDER_AFTER = [
aioxmpp.DiscoClient,
]
async def _check_for_feature(self):
disco_client = self.dependencies[aioxmpp.DiscoClient]
info = await disco_client.query_info(
self.client.local_jid.replace(
localpart=None,
resource=None,
)
)
if namespaces.xep0280_carbons_2 not in info.features:
raise RuntimeError(
"Message Carbons ({}) are not supported by the server".format(
namespaces.xep0280_carbons_2
)
)
async def enable(self):
"""
Enable message carbons.
:raises RuntimeError: if the server does not support message carbons.
:raises aioxmpp.XMPPError: if the server responded with an error to the
request.
:raises: as specified in :meth:`aioxmpp.Client.send`
"""
await self._check_for_feature()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=carbons_xso.Enable()
)
await self.client.send(iq)
async def disable(self):
"""
Disable message carbons.
:raises RuntimeError: if the server does not support message carbons.
:raises aioxmpp.XMPPError: if the server responded with an error to the
request.
:raises: as specified in :meth:`aioxmpp.Client.send`
"""
await self._check_for_feature()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=carbons_xso.Disable()
)
await self.client.send(iq)
@@ -0,0 +1,105 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
from ..misc import Forwarded
from ..stanza import Message, IQ
namespaces.xep0280_carbons_2 = "urn:xmpp:carbons:2"
@IQ.as_payload_class
class Enable(xso.XSO):
TAG = (namespaces.xep0280_carbons_2, "enable")
@IQ.as_payload_class
class Disable(xso.XSO):
TAG = (namespaces.xep0280_carbons_2, "disable")
class _CarbonsWrapper(xso.XSO):
forwarded = xso.Child([Forwarded])
@property
def stanza(self):
"""
The wrapped stanza, usually a :class:`aioxmpp.Message`.
Internally, this accesses the :attr:`~.misc.Forwarded.stanza` attribute
of :attr:`forwarded`. If :attr:`forwarded` is :data:`None`, reading
this attribute returns :data:`None`. Writing to this attribute creates
a new :class:`~.misc.Forwarded` object if necessary, but re-uses an
existing object if available.
"""
if self.forwarded is None:
return None
return self.forwarded.stanza
@stanza.setter
def stanza(self, value):
if self.forwarded is None:
self.forwarded = Forwarded()
self.forwarded.stanza = value
class Sent(_CarbonsWrapper):
"""
Wrap a stanza which was sent by another entity of the same account.
:class:`Sent` XSOs are available in Carbon messages at
:attr:`aioxmpp.Message.xep0280_sent`.
.. autoattribute:: stanza
.. attribute:: forwarded
The full :class:`~.misc.Forwarded` object which holds the sent stanza.
"""
TAG = (namespaces.xep0280_carbons_2, "sent")
class Received(_CarbonsWrapper):
"""
Wrap a stanza which was received by another entity of the same account.
:class:`Received` XSOs are available in Carbon messages at
:attr:`aioxmpp.Message.xep0280_received`.
.. autoattribute:: stanza
.. attribute:: forwarded
The full :class:`~.misc.Forwarded` object which holds the received
stanza.
"""
TAG = (namespaces.xep0280_carbons_2, "received")
Message.xep0280_sent = xso.Child([Sent])
Message.xep0280_received = xso.Child([Received])
@@ -0,0 +1,58 @@
########################################################################
# File name: __init__.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.chatstates` Chat State Notification support (:xep:`0085`)
##########################################################################
This module provides support to implement :xep:`Chat State
Notifications <85>`.
XSOs
====
The module registers an attribute ``xep0085_chatstate`` with
:class:`aioxmpp.Message` to represent the chat state
notification tags, it takes values from the following enumeration (or
:data:`None` if no tag is present):
.. autoclass:: ChatState
Helpers
=======
The module provides the following helper class, that handles the state
management for chat state notifications:
.. autoclass:: ChatStateManager
Its operation is controlled by one of the chat state strategies:
.. autoclass:: DoNotEmit
.. autoclass:: DiscoverSupport
.. autoclass:: AlwaysEmit
"""
from .xso import ChatState # NOQA: F401
from .utils import (ChatStateManager, DoNotEmit, AlwaysEmit, # NOQA: F401
DiscoverSupport)
@@ -0,0 +1,152 @@
########################################################################
# File name: utils.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/>.
#
########################################################################
from abc import ABCMeta, abstractproperty
from . import xso as chatstates_xso
class ChatStateStrategy(metaclass=ABCMeta):
@abstractproperty
def sending(self):
"""
Return whether to send chat state notifications.
"""
raise NotImplementedError # pragma: no cover
def reset(self):
"""
Reset the strategy (called after a reconnect).
"""
pass
def no_reply(self):
"""
Called when the replies did not include a chat state.
"""
pass
class DoNotEmit(ChatStateStrategy):
"""
Chat state strategy: Do not emit chat state notifications.
"""
@property
def sending(self):
return False
class DiscoverSupport(ChatStateStrategy):
"""
Chat state strategy: Discover support for chat state notifications
as per section 5.1 of :xep:`0085`.
"""
def __init__(self):
self.state = True
def reset(self):
self.state = True
def no_reply(self):
self.state = False
@property
def sending(self):
return self.state
class AlwaysEmit(ChatStateStrategy):
"""
Chat state strategy: Always emit chat state notifications.
"""
@property
def sending(self):
return True
class ChatStateManager:
"""
Manage the state of our chat state.
:param strategy: the strategy used to decide whether to send
notifications (defaults to :class:`DiscoverSupport`)
:type strategy: a subclass of :class:`ChatStateStrategy`
.. automethod:: handle
Methods to pass in protocol level information:
.. automethod:: no_reply
.. automethod:: reset
"""
def __init__(self, strategy=None):
self._state = chatstates_xso.ChatState.ACTIVE
if strategy is None:
strategy = DiscoverSupport()
self._strategy = strategy
def handle(self, state, message=False):
"""
Handle a state update.
:param state: the new chat state
:type state: :class:`~aioxmpp.chatstates.ChatState`
:param message: pass true to indicate that we handle the
:data:`ACTIVE` state that is implied by
sending a content message.
:type message: :class:`bool`
:returns: whether a standalone notification must be sent for
this state update, respective if a chat state
notification must be included with the message.
:raises ValueError: if `message` is true and a state other
than :data:`ACTIVE` is passed.
"""
if message:
if state != chatstates_xso.ChatState.ACTIVE:
raise ValueError(
"Only the state ACTIVE can be sent with messages."
)
elif self._state == state:
return False
self._state = state
return self._strategy.sending
def no_reply(self):
"""
Call this method if the peer did not include a chat state
notification.
"""
self._strategy.no_reply()
def reset(self):
"""
Call this method on connection reset.
"""
self._strategy.reset()
@@ -0,0 +1,54 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import enum
import aioxmpp.xso as xso
import aioxmpp.stanza as stanza
from aioxmpp.utils import namespaces
namespaces.xep0085 = "http://jabber.org/protocol/chatstates"
class ChatState(enum.Enum):
"""
Enumeration of the chat states defined by :xep:`0085`:
.. attribute:: ACTIVE
.. attribute:: COMPOSING
.. attribute:: PAUSED
.. attribute:: INACTIVE
.. attribute:: GONE
"""
ACTIVE = (namespaces.xep0085, "active")
COMPOSING = (namespaces.xep0085, "composing")
PAUSED = (namespaces.xep0085, "paused")
INACTIVE = (namespaces.xep0085, "inactive")
GONE = (namespaces.xep0085, "gone")
stanza.Message.xep0085_chatstate = xso.ChildTag(ChatState, allow_none=True)
@@ -0,0 +1,382 @@
########################################################################
# File name: connector.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.connector` --- Ways to establish XML streams
###########################################################
This module provides classes to establish XML streams. Currently, there are two
different ways to establish XML streams: normal TCP connection which is then
upgraded using STARTTLS, and directly using TLS.
.. versionadded:: 0.6
The whole module was added in version 0.6.
Abstract base class
===================
The connectors share a common abstract base class, :class:`BaseConnector`:
.. autoclass:: BaseConnector
Specific connectors
===================
.. autoclass:: STARTTLSConnector
.. autoclass:: XMPPOverTLSConnector
"""
import abc
import asyncio
import logging
from datetime import timedelta
import aioxmpp.errors as errors
import aioxmpp.nonza as nonza
import aioxmpp.protocol as protocol
import aioxmpp.ssl_transport as ssl_transport
def to_ascii(s):
return s.encode("idna").decode("ascii")
class BaseConnector(metaclass=abc.ABCMeta):
"""
This is the base class for connectors. It defines the public interface of
all connectors.
.. autoattribute:: tls_supported
.. automethod:: connect
Existing connectors:
.. autosummary::
STARTTLSConnector
XMPPOverTLSConnector
"""
@abc.abstractproperty
def tls_supported(self):
"""
Boolean which indicates whether TLS is supported by this connector.
"""
@abc.abstractproperty
def dane_supported(self):
"""
Boolean which indicates whether DANE is supported by this connector.
"""
@abc.abstractmethod
async def connect(self, loop, metadata, domain, host, port,
negotiation_timeout,
base_logger=None):
"""
Establish a :class:`.protocol.XMLStream` for `domain` with the given
`host` at the given TCP `port`.
`metadata` must be a :class:`.security_layer.SecurityLayer` instance to
use for the connection. `loop` must be a :class:`asyncio.BaseEventLoop`
to use.
`negotiation_timeout` must be the maximum time in seconds to wait for
the server to reply in each negotiation step. The `negotiation_timeout`
is used as value for
:attr:`~aioxmpp.protocol.XMLStream.deadtime_hard_limit` in the returned
stream.
Return a triple consisting of the :class:`asyncio.Transport`, the
:class:`.protocol.XMLStream` and the
:class:`aioxmpp.nonza.StreamFeatures` of the stream.
To detect the use of TLS on the stream, check whether
:meth:`asyncio.Transport.get_extra_info` returns a non-:data:`None`
value for ``"ssl_object"``.
`base_logger` is passed to :class:`aioxmpp.protocol.XMLStream`.
.. versionchanged:: 0.10
Assignment of
:attr:`~aioxmpp.protocol.XMLStream.deadtime_hard_limit` was added.
"""
class STARTTLSConnector(BaseConnector):
"""
Establish an XML stream using STARTTLS.
.. automethod:: connect
"""
@property
def tls_supported(self):
return True
@property
def dane_supported(self):
return False
async def connect(self, loop, metadata, domain: str, host, port,
negotiation_timeout, base_logger=None):
"""
.. seealso::
:meth:`BaseConnector.connect`
For general information on the :meth:`connect` method.
Connect to `host` at TCP port number `port`. The
:class:`aioxmpp.security_layer.SecurityLayer` object `metadata` is used
to determine the parameters of the TLS connection.
First, a normal TCP connection is opened and the stream header is sent.
The stream features are waited for, and then STARTTLS is negotiated if
possible.
:attr:`~.security_layer.SecurityLayer.tls_required` is honoured: if it
is true and TLS negotiation fails, :class:`~.errors.TLSUnavailable` is
raised. TLS negotiation is always attempted if
:attr:`~.security_layer.SecurityLayer.tls_required` is true, even if
the server does not advertise a STARTTLS stream feature. This might
help to prevent trivial downgrade attacks, and we dont have anything
to lose at this point anymore anyways.
:attr:`~.security_layer.SecurityLayer.ssl_context_factory` and
:attr:`~.security_layer.SecurityLayer.certificate_verifier_factory` are
used to configure the TLS connection.
.. versionchanged:: 0.10
The `negotiation_timeout` is set as
:attr:`~.XMLStream.deadtime_hard_limit` on the returned XML stream.
"""
features_future = asyncio.Future(loop=loop)
stream = protocol.XMLStream(
to=domain,
features_future=features_future,
base_logger=base_logger,
)
if base_logger is not None:
logger = base_logger.getChild(type(self).__name__)
else:
logger = logging.getLogger(".".join([
__name__, type(self).__qualname__,
]))
try:
transport, _ = await ssl_transport.create_starttls_connection(
loop,
lambda: stream,
host=host,
port=port,
peer_hostname=host,
server_hostname=to_ascii(domain),
use_starttls=True,
)
except: # NOQA
stream.abort()
raise
stream.deadtime_hard_limit = timedelta(seconds=negotiation_timeout)
features = await features_future
try:
features[nonza.StartTLSFeature]
except KeyError:
if not metadata.tls_required:
return transport, stream, await features_future
logger.debug(
"attempting STARTTLS despite not announced since it is"
" required")
try:
response = await protocol.send_and_wait_for(
stream,
[
nonza.StartTLS(),
],
[
nonza.StartTLSFailure,
nonza.StartTLSProceed,
]
)
except errors.StreamError:
raise errors.TLSUnavailable(
"STARTTLS not supported by server, but required by client"
)
if not isinstance(response, nonza.StartTLSProceed):
if metadata.tls_required:
message = (
"server failed to STARTTLS"
)
protocol.send_stream_error_and_close(
stream,
condition=errors.StreamErrorCondition.POLICY_VIOLATION,
text=message,
)
raise errors.TLSUnavailable(message)
return transport, stream, await features_future
verifier = metadata.certificate_verifier_factory()
await verifier.pre_handshake(
domain,
host,
port,
metadata,
)
ssl_context = metadata.ssl_context_factory()
verifier.setup_context(ssl_context, transport)
await stream.starttls(
ssl_context=ssl_context,
post_handshake_callback=verifier.post_handshake,
)
features = await protocol.reset_stream_and_get_features(
stream,
timeout=negotiation_timeout,
)
return transport, stream, features
class XMPPOverTLSConnector(BaseConnector):
"""
Establish an XML stream using XMPP-over-TLS, as per :xep:`368`.
.. automethod:: connect
"""
@property
def dane_supported(self):
return False
@property
def tls_supported(self):
return True
def _context_factory_factory(self, logger, metadata, verifier):
def context_factory(transport):
ssl_context = metadata.ssl_context_factory()
if hasattr(ssl_context, "set_alpn_protos"):
try:
ssl_context.set_alpn_protos([b'xmpp-client'])
except NotImplementedError:
logger.warning(
"the underlying OpenSSL library does not support ALPN"
)
else:
logger.warning(
"OpenSSL.SSL.Context lacks set_alpn_protos - "
"please update pyOpenSSL to a recent version"
)
verifier.setup_context(ssl_context, transport)
return ssl_context
return context_factory
async def connect(self, loop, metadata, domain, host, port,
negotiation_timeout, base_logger=None):
"""
.. seealso::
:meth:`BaseConnector.connect`
For general information on the :meth:`connect` method.
Connect to `host` at TCP port number `port`. The
:class:`aioxmpp.security_layer.SecurityLayer` object `metadata` is used
to determine the parameters of the TLS connection.
The connector connects to the server by directly establishing TLS; no
XML stream is started before TLS negotiation, in accordance to
:xep:`368` and how legacy SSL was handled in the past.
:attr:`~.security_layer.SecurityLayer.ssl_context_factory` and
:attr:`~.security_layer.SecurityLayer.certificate_verifier_factory` are
used to configure the TLS connection.
.. versionchanged:: 0.10
The `negotiation_timeout` is set as
:attr:`~.XMLStream.deadtime_hard_limit` on the returned XML stream.
"""
features_future = asyncio.Future(loop=loop)
stream = protocol.XMLStream(
to=domain,
features_future=features_future,
base_logger=base_logger,
)
if base_logger is not None:
logger = base_logger.getChild(type(self).__name__)
else:
logger = logging.getLogger(".".join([
__name__, type(self).__qualname__,
]))
verifier = metadata.certificate_verifier_factory()
await verifier.pre_handshake(
domain,
host,
port,
metadata,
)
context_factory = self._context_factory_factory(logger, metadata,
verifier)
try:
transport, _ = await ssl_transport.create_starttls_connection(
loop,
lambda: stream,
host=host,
port=port,
peer_hostname=host,
server_hostname=to_ascii(domain),
post_handshake_callback=verifier.post_handshake,
ssl_context_factory=context_factory,
use_starttls=False,
)
except: # NOQA
stream.abort()
raise
stream.deadtime_hard_limit = timedelta(seconds=negotiation_timeout)
return transport, stream, await features_future
@@ -0,0 +1,76 @@
########################################################################
# File name: custom_queue.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/>.
#
########################################################################
import asyncio
import collections
class AsyncDeque:
def __init__(self, *, loop=None):
super().__init__()
self._loop = loop
self._data = collections.deque()
self._non_empty = asyncio.Event()
self._non_empty.clear()
def __len__(self):
return len(self._data)
def __contains__(self, obj):
return obj in self._data
def empty(self):
return not self._non_empty.is_set()
def put_nowait(self, obj):
self._data.append(obj)
self._non_empty.set()
def putleft_nowait(self, obj):
self._data.appendleft(obj)
self._non_empty.set()
def get_nowait(self):
try:
item = self._data.popleft()
except IndexError:
raise asyncio.QueueEmpty() from None
if not self._data:
self._non_empty.clear()
return item
def getright_nowait(self):
try:
item = self._data.pop()
except IndexError:
raise asyncio.QueueEmpty() from None
if not self._data:
self._non_empty.clear()
return item
async def get(self):
while not self._data:
await self._non_empty.wait()
return self.get_nowait()
def clear(self):
self._data.clear()
self._non_empty.clear()
@@ -0,0 +1,119 @@
########################################################################
# File name: __init__.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.disco` --- Service discovery support (:xep:`0030`)
#################################################################
This module provides support for :xep:`Service Discovery <30>`. For this, it
provides a :class:`~aioxmpp.service.Service` subclass which can be loaded into
a client using :meth:`.Client.summon`.
Services
========
The following services are provided by this subpackage and available directly
from :mod:`aioxmpp`:
.. currentmodule:: aioxmpp
.. autosummary::
:nosignatures:
DiscoServer
DiscoClient
.. versionchanged:: 0.8
Prior to version 0.8, both services were provided by a single class
(:class:`aioxmpp.disco.Service`). This is not the case anymore, and there is
no replacement.
If you need to write backwards compatible code, you could be doing something
like this::
try:
aioxmpp.DiscoServer
except AttributeError:
aioxmpp.DiscoServer = aioxmpp.disco.Service
aioxmpp.DiscoClient = aioxmpp.disco.Service
This should work, because the old :class:`Service` class provided the
features of both of the individual classes.
The detailed documentation of the classes follows:
.. autoclass:: DiscoServer
.. autoclass:: DiscoClient
.. currentmodule:: aioxmpp.disco
Entity information
------------------
.. autoclass:: Node
.. autoclass:: StaticNode
.. autoclass:: mount_as_node
.. autoclass:: register_feature
.. autoclass:: RegisteredFeature
.. module:: aioxmpp.disco.xso
.. currentmodule:: aioxmpp.disco.xso
:mod:`.disco.xso` --- IQ payloads
=================================
The submodule :mod:`aioxmpp.disco.xso` contains the :class:`~aioxmpp.xso.XSO`
classes which describe the IQ payloads used by this subpackage.
You will encounter some of these in return values, but there should never be a
need to construct them by yourself; the :class:`~aioxmpp.disco.Service` handles
it all.
Information queries
-------------------
.. autoclass:: InfoQuery(*[, identities][, features][, node])
.. autoclass:: Feature(*[, var])
.. autoclass:: Identity(*[, category][, type_][, name][, lang])
Item queries
------------
.. autoclass:: ItemsQuery(*[, node][, items])
.. autoclass:: Item(*[, jid][, name][, node])
.. currentmodule:: aioxmpp.disco
"""
from . import xso # NOQA: F401
from .service import (DiscoClient, DiscoServer, Node, StaticNode, # NOQA: F401
mount_as_node, register_feature, RegisteredFeature)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
########################################################################
# File name: xso.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/>.
#
########################################################################
import aioxmpp.forms.xso as forms_xso
import aioxmpp.stanza as stanza
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0030_info = "http://jabber.org/protocol/disco#info"
namespaces.xep0030_items = "http://jabber.org/protocol/disco#items"
class Identity(xso.XSO):
"""
An identity declaration. The keyword arguments to the constructor can be
used to initialize attributes of the :class:`Identity` instance.
.. attribute:: category
The category of the identity. The value is not validated against the
values in the `registry
<https://xmpp.org/registrar/disco-categories.html>`_.
.. attribute:: type_
The type of the identity. The value is not validated against the values
in the `registry
<https://xmpp.org/registrar/disco-categories.html>`_.
.. attribute:: name
The optional human-readable name of the identity. See also the
:attr:`lang` attribute.
.. attribute:: lang
The language of the :attr:`name`. This may be not :data:`None` even if
:attr:`name` is not set due to ``xml:lang`` propagation.
"""
TAG = (namespaces.xep0030_info, "identity")
category = xso.Attr(tag="category")
type_ = xso.Attr(tag="type")
name = xso.Attr(tag="name", default=None)
lang = xso.LangAttr()
def __init__(self, *,
category="client",
type_="bot",
name=None,
lang=None):
super().__init__()
self.category = category
self.type_ = type_
if name is not None:
self.name = name
if lang is not None:
self.lang = lang
def __eq__(self, other):
try:
return (self.category == other.category and
self.type_ == other.type_ and
self.name == other.name and
self.lang == other.lang)
except AttributeError:
return NotImplemented
def __repr__(self):
return "{}.{}(category={!r}, type_={!r}, name={!r}, lang={!r})".format(
self.__class__.__module__,
self.__class__.__qualname__,
self.category,
self.type_,
self.name,
self.lang)
class Feature(xso.XSO):
"""
A feature declaration. The keyword argument to the constructor can be used
to initialize the attribute of the :class:`Feature` instance.
.. attribute:: var
The namespace which identifies the feature.
"""
TAG = (namespaces.xep0030_info, "feature")
var = xso.Attr(tag="var")
def __init__(self, var):
super().__init__()
self.var = var
class FeatureSet(xso.AbstractElementType):
def get_xso_types(self):
return [Feature]
def unpack(self, item):
return item.var
def pack(self, var):
return Feature(var)
@stanza.IQ.as_payload_class
class InfoQuery(xso.CapturingXSO):
"""
A query for features and identities of an entity. The keyword arguments to
the constructor can be used to initialize the attributes. Note that
`identities` and `features` must be iterables of :class:`Identity` and
:class:`Feature`, respectively; these iterables are evaluated and the items
are stored in the respective attributes.
.. attribute:: node
The node at which the query is directed.
.. attribute:: identities
The identities of the entity, as :class:`Identity` instances. Each
entity has at least one identity.
.. attribute:: features
The features of the entity, as a set of strings. Each string represents
a :class:`Feature` instance with the corresponding :attr:`~.Feature.var`
attribute.
.. attribute:: captured_events
If the object was created by parsing an XML stream, this attribute holds
a list of events which were used when parsing it.
Otherwise, this is :data:`None`.
.. versionadded:: 0.5
.. automethod:: to_dict
"""
__slots__ = ("captured_events",)
TAG = (namespaces.xep0030_info, "query")
node = xso.Attr(tag="node", default=None)
identities = xso.ChildList([Identity])
features = xso.ChildValueList(
FeatureSet(),
container_type=set
)
exts = xso.ChildList([forms_xso.Data])
def __init__(self, *, identities=(), features=(), node=None):
super().__init__()
self.captured_events = None
self.identities.extend(identities)
self.features.update(features)
if node is not None:
self.node = node
def to_dict(self):
"""
Convert the query result to a normalized JSON-like
representation.
The format is a subset of the format used by the `capsdb`__. Obviously,
the node name and hash type are not included; otherwise, the format is
identical.
__ https://github.com/xnyhps/capsdb
"""
identities = []
for identity in self.identities:
identity_dict = {
"category": identity.category,
"type": identity.type_,
}
if identity.lang is not None:
identity_dict["lang"] = identity.lang.match_str
if identity.name is not None:
identity_dict["name"] = identity.name
identities.append(identity_dict)
features = sorted(self.features)
forms = []
for form in self.exts:
forms.append({
field.var: list(field.values)
for field in form.fields
if field.var is not None
})
result = {
"identities": identities,
"features": features,
"forms": forms
}
return result
def _set_captured_events(self, events):
self.captured_events = events
class Item(xso.XSO):
"""
An item declaration. The keyword arguments to the constructor can be used
to initialize the attributes of the :class:`Item` instance.
.. attribute:: jid
:class:`~aioxmpp.JID` of the entity represented by the item.
.. attribute:: node
Node of the item
.. attribute:: name
Name of the item
"""
TAG = (namespaces.xep0030_items, "item")
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
jid = xso.Attr(
tag="jid",
type_=xso.JID(),
# FIXME: validator for full jid
)
name = xso.Attr(
tag="name",
default=None,
)
node = xso.Attr(
tag="node",
default=None,
)
def __init__(self, jid, name=None, node=None):
super().__init__()
self.jid = jid
self.name = name
self.node = node
@stanza.IQ.as_payload_class
class ItemsQuery(xso.XSO):
"""
A query for items at a specific entity. The keyword arguments to the
constructor can be used to initialize the attributes of the
:class:`ItemsQuery`. Note that `items` must be an iterable of :class:`Item`
instances. The iterable will be evaluated and the items will be stored in
the :attr:`items` attribute.
.. attribute:: node
Node at which the query is directed
.. attribute:: items
The items at the addressed entity.
"""
TAG = (namespaces.xep0030_items, "query")
node = xso.Attr(tag="node", default=None)
items = xso.ChildList([Item])
def __init__(self, *, node=None, items=()):
super().__init__()
self.items.extend(items)
if node is not None:
self.node = node
@@ -0,0 +1,451 @@
########################################################################
# File name: dispatcher.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.dispatcher` --- Dispatch stanzas to callbacks
############################################################
.. versionadded:: 0.9
The whole module was added in 0.9.
Stanza Dispatchers for Messages and Presences
=============================================
.. autoclass:: SimpleMessageDispatcher
.. autoclass:: SimplePresenceDispatcher
Decorators for :class:`aioxmpp.service.Service` Methods
=======================================================
.. autodecorator:: message_handler
.. autodecorator:: presence_handler
Test Functions
--------------
.. autofunction:: is_message_handler
.. autofunction:: is_presence_handler
Base Class for Stanza Dispatchers
=================================
.. autoclass:: SimpleStanzaDispatcher
"""
import abc
import asyncio
import contextlib
import aioxmpp.service
import aioxmpp.stream
class SimpleStanzaDispatcher(metaclass=abc.ABCMeta):
"""
Dispatch stanzas based on their sender and type.
This is a service base class (not a service you should summon) which can be
used to implement simple, pre-0.9 presence and message dispatching.
For users, the following methods are relevant:
.. automethod:: register_callback
.. automethod:: unregister_callback
.. automethod:: handler_context
For deriving classes, the following methods are relevant:
.. automethod:: _feed
Subclasses must also provide the following property:
.. autoattribute:: local_jid
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._map = {}
@abc.abstractproperty
def local_jid(self):
"""
The bare JID of the client for which this dispatcher is used.
This is required to map missing ``@from`` attributes to this JID. The
attribute must be provided by implementing subclasses.
"""
def _feed(self, stanza):
"""
Dispatch the given `stanza`.
:param stanza: Stanza to dispatch
:type stanza: :class:`~.StanzaBase`
:rtype: :class:`bool`
:return: true if the stanza was dispatched, false otherwise.
Dispatch the stanza to up to one handler registered on the dispatcher.
If no handler is found for the stanza, :data:`False` is returned.
Otherwise, :data:`True` is returned.
"""
from_ = stanza.from_
if from_ is None:
from_ = self.local_jid
keys = [
(stanza.type_, from_, False),
(stanza.type_, from_.bare(), True),
(None, from_, False),
(None, from_.bare(), True),
(stanza.type_, None, False),
(None, from_, False),
(None, None, False),
]
for key in keys:
try:
cb = self._map[key]
except KeyError:
continue
cb(stanza)
return
def register_callback(self, type_, from_, cb, *,
wildcard_resource=True):
"""
Register a callback function.
:param type_: Stanza type to listen for, or :data:`None` for a
wildcard match.
:param from_: Sender to listen for, or :data:`None` for a full wildcard
match.
:type from_: :class:`aioxmpp.JID` or :data:`None`
:param cb: Callback function to register
:param wildcard_resource: Whether to wildcard the resourcepart of the
JID.
:type wildcard_resource: :class:`bool`
:raises ValueError: if another function is already registered for the
callback slot.
`cb` will be called whenever a stanza with the matching `type_` and
`from_` is processed. The following wildcarding rules apply:
1. If the :attr:`~aioxmpp.stanza.StanzaBase.from_` attribute of the
stanza has a resourcepart, the following lookup order for callbacks is used:
+---------------------------+----------------------------------+----------------------+
|``type_`` |``from_`` |``wildcard_resource`` |
+===========================+==================================+======================+
|:attr:`~.StanzaBase.type_` |:attr:`~.StanzaBase.from_` |*any* |
+---------------------------+----------------------------------+----------------------+
|:attr:`~.StanzaBase.type_` |*bare* :attr:`~.StanzaBase.from_` |:data:`True` |
+---------------------------+----------------------------------+----------------------+
|:data:`None` |:attr:`~.StanzaBase.from_` |*any* |
+---------------------------+----------------------------------+----------------------+
|:data:`None` |*bare* :attr:`~.StanzaBase.from_` |:data:`True` |
+---------------------------+----------------------------------+----------------------+
|:attr:`~.StanzaBase.type_` |:data:`None` |*any* |
+---------------------------+----------------------------------+----------------------+
|:data:`None` |:data:`None` |*any* |
+---------------------------+----------------------------------+----------------------+
2. If the :attr:`~aioxmpp.stanza.StanzaBase.from_` attribute of the
stanza does *not* have a resourcepart, the following lookup order
for callbacks is used:
+---------------------------+---------------------------+----------------------+
|``type_`` |``from_`` |``wildcard_resource`` |
+===========================+===========================+======================+
|:attr:`~.StanzaBase.type_` |:attr:`~.StanzaBase.from_` |:data:`False` |
+---------------------------+---------------------------+----------------------+
|:data:`None` |:attr:`~.StanzaBase.from_` |:data:`False` |
+---------------------------+---------------------------+----------------------+
|:attr:`~.StanzaBase.type_` |:data:`None` |*any* |
+---------------------------+---------------------------+----------------------+
|:data:`None` |:data:`None` |*any* |
+---------------------------+---------------------------+----------------------+
Only the first callback which matches is called. `wildcard_resource` is
ignored if `from_` is a full JID or :data:`None`.
.. note::
When the server sends a stanza without from attribute, it is
replaced with the bare :attr:`local_jid`, as per :rfc:`6120`.
""" # NOQA: E501
if from_ is None or not from_.is_bare:
wildcard_resource = False
key = (type_, from_, wildcard_resource)
if key in self._map:
raise ValueError(
"only one listener allowed per matcher"
)
self._map[type_, from_, wildcard_resource] = cb
def unregister_callback(self, type_, from_, *,
wildcard_resource=True):
"""
Unregister a callback function.
:param type_: Stanza type to listen for, or :data:`None` for a
wildcard match.
:param from_: Sender to listen for, or :data:`None` for a full wildcard
match.
:type from_: :class:`aioxmpp.JID` or :data:`None`
:param wildcard_resource: Whether to wildcard the resourcepart of the
JID.
:type wildcard_resource: :class:`bool`
The callback must be disconnected with the same arguments as were used
to connect it.
"""
if from_ is None or not from_.is_bare:
wildcard_resource = False
self._map.pop((type_, from_, wildcard_resource))
@contextlib.contextmanager
def handler_context(self, type_, from_, cb, *, wildcard_resource=True):
"""
Context manager which temporarily registers a callback.
The arguments are the same as for :meth:`register_callback`.
When the context is entered, the callback `cb` is registered. When the
context is exited, no matter if an exception is raised or not, the
callback is unregistered.
"""
self.register_callback(
type_, from_, cb,
wildcard_resource=wildcard_resource
)
try:
yield
finally:
self.unregister_callback(
type_, from_,
wildcard_resource=wildcard_resource
)
class SimpleMessageDispatcher(aioxmpp.service.Service,
SimpleStanzaDispatcher):
"""
Dispatch messages to callbacks.
This :class:`~aioxmpp.service.Service` dispatches :class:`~aioxmpp.Message`
stanzas to callbacks. Callbacks registrations are managed with the
:meth:`.SimpleStanzaDispatcher.register_callback` and
:meth:`.SimpleStanzaDispatcher.unregister_callback` methods of the base
class. The `type_` argument to these methods must be a
:class:`aioxmpp.MessageType` or :data:`None` to make any sense.
.. note::
It is not recommended to mix the use of a
:class:`SimpleMessageDispatcher` with the modern Instant Messaging
features provided by the :mod:`aioxmpp.im` module. Both will receive the
messages and this may thus lead to duplicate messages.
"""
@property
def local_jid(self):
return self.client.local_jid
@aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream,
"on_message_received")
def _feed(self, stanza):
super()._feed(stanza)
class SimplePresenceDispatcher(aioxmpp.service.Service,
SimpleStanzaDispatcher):
"""
Dispatch presences to callbacks.
This :class:`~aioxmpp.service.Service` dispatches
:class:`~aioxmpp.Presence` stanzas to callbacks. Callbacks registrations
are managed with the :meth:`.SimpleStanzaDispatcher.register_callback` and
:meth:`.SimpleStanzaDispatcher.unregister_callback` methods of the base
class. The `type_` argument to these methods must be a
:class:`aioxmpp.MessageType` or :data:`None` to make any sense.
.. warning::
It is not recommended to mix the use of a
:class:`SimplePresenceDispatcher` with :class:`aioxmpp.RosterClient` and
:class:`aioxmpp.PresenceClient`. Both of these register callbacks at the
:class:`SimplePresenceDispatcher`. Registering callbacks for different
slots will either make those callbacks not be called at all or will
make the services miss stanzas.
"""
@property
def local_jid(self):
return self.client.local_jid
@aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream,
"on_presence_received")
def _feed(self, stanza):
super()._feed(stanza)
def _apply_message_handler(instance, stream, func, type_, from_):
return instance.dependencies[SimpleMessageDispatcher].handler_context(
type_,
from_,
func,
)
def _apply_presence_handler(instance, stream, func, type_, from_):
return instance.dependencies[SimplePresenceDispatcher].handler_context(
type_,
from_,
func,
)
def message_handler(type_, from_):
"""
Register the decorated function as message handler.
:param type_: Message type to listen for
:type type_: :class:`~.MessageType`
:param from_: Sender JIDs to listen for
:type from_: :class:`aioxmpp.JID` or :data:`None`
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:meth:`~.StanzaStream.register_message_callback`
for more details on the `type_` and `from_` arguments
.. versionchanged:: 0.9
This is now based on
:class:`aioxmpp.dispatcher.SimpleMessageDispatcher`.
"""
def decorator(f):
if asyncio.iscoroutinefunction(f):
raise TypeError("message_handler must not be a coroutine function")
aioxmpp.service.add_handler_spec(
f,
aioxmpp.service.HandlerSpec(
(_apply_message_handler, (type_, from_)),
require_deps=(
SimpleMessageDispatcher,
)
)
)
return f
return decorator
def presence_handler(type_, from_):
"""
Register the decorated function as presence stanza handler.
:param type_: Presence type to listen for
:type type_: :class:`~.PresenceType`
:param from_: Sender JIDs to listen for
:type from_: :class:`aioxmpp.JID` or :data:`None`
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:meth:`~.StanzaStream.register_presence_callback`
for more details on the `type_` and `from_` arguments
.. versionchanged:: 0.9
This is now based on
:class:`aioxmpp.dispatcher.SimplePresenceDispatcher`.
"""
def decorator(f):
if asyncio.iscoroutinefunction(f):
raise TypeError(
"presence_handler must not be a coroutine function"
)
aioxmpp.service.add_handler_spec(
f,
aioxmpp.service.HandlerSpec(
(_apply_presence_handler, (type_, from_)),
require_deps=(
SimplePresenceDispatcher,
)
)
)
return f
return decorator
def is_message_handler(type_, from_, cb):
"""
Return true if `cb` has been decorated with :func:`message_handler` for the
given `type_` and `from_`.
"""
try:
handlers = aioxmpp.service.get_magic_attr(cb)
except AttributeError:
return False
return aioxmpp.service.HandlerSpec(
(_apply_message_handler, (type_, from_)),
require_deps=(
SimpleMessageDispatcher,
)
) in handlers
def is_presence_handler(type_, from_, cb):
"""
Return true if `cb` has been decorated with :func:`presence_handler` for
the given `type_` and `from_`.
"""
try:
handlers = aioxmpp.service.get_magic_attr(cb)
except AttributeError:
return False
return aioxmpp.service.HandlerSpec(
(_apply_presence_handler, (type_, from_)),
require_deps=(
SimplePresenceDispatcher,
)
) in handlers
@@ -0,0 +1,499 @@
########################################################################
# File name: __init__.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.e2etest` --- Framework for writing integration tests for :mod:`aioxmpp`
######################################################################################
This subpackage provides utilities for writing end-to-end or intgeration tests
for :mod:`aioxmpp` components.
.. warning::
For now, the API of this subpackage is classified as internal. Please do not
test your external components using this API, as it is experimental and
subject to change.
Overview
========
The basic concept is that tests are written like normal unittests. However,
tests are written by inheriting classes from :class:`aioxmpp.e2etest.TestCase`
instead of :mod:`unittest.TestCase`. :class:`.e2etest.TestCase` has the
:attr:`~.e2etest.TestCase.provisioner` attribute which provides access to a
:class:`.provision.Provisioner` instance.
Provisioners are objects which provide a way to obtain a connected XMPP client.
The JID to which the client is bound is unspecified; however, each client gets
a unique bare JID and the clients are able to communicate with each other. In
addition, provisioners provide information about the environment in which the
clients act. This includes providing JIDs of entities implementing specific
protocols or features. The details are explained in the documentation of the
:class:`~.provision.Provisioner` base class.
By default, tests which are written with :class:`.e2etest.TestCase` are skipped
when using the normal test runners. This is because the provisioners need to be
configured; this is handled using a custom nosetests plugin which is not loaded
by default (for good reasons). To run the tests, use (instead of the normal
``nosetests3`` binary):
.. code-block:: console
$ python3 -m aioxmpp.e2etest
The command line interface is identical to the one of ``nosetests3``, except
that additional options are provided to configure the plugin. In fact,
:mod:`aioxmpp.e2etest` is simply a nose test runner with an additional plugin.
By default, the configuration is read from ``./.local/e2etest.ini``. For
details on configuring the provisioners, see :ref:`the developer guide
<dg-end-to-end-tests>`.
Main API
========
Decorators for test methods
---------------------------
The following decorators can be used on test methods (including ``setUp`` and
``tearDown``):
.. autodecorator:: require_feature
.. autodecorator:: require_identity
.. autodecorator:: require_feature_subset
.. autodecorator:: skip_with_quirk
General decorators
------------------
.. autodecorator:: blocking()
.. autodecorator:: blocking_timed()
.. autodecorator:: blocking_with_timeout
Class for test cases
--------------------
.. autoclass:: TestCase
.. currentmodule:: aioxmpp.e2etest.provision
Provisioners
============
.. autoclass:: Provisioner
.. autoclass:: AnonymousProvisioner()
.. autoclass:: AnyProvisioner()
.. autoclass:: StaticPasswordProvisioner()
.. currentmodule:: aioxmpp.e2etest
.. autoclass:: Quirk
.. currentmodule:: aioxmpp.e2etest.provision
Helper functions
----------------
.. autofunction:: discover_server_features
.. autofunction:: configure_tls_config
.. autofunction:: configure_quirks
""" # NOQA: E501
import asyncio
import configparser
import functools
import importlib
import logging
import os
import unittest
import pytest
from ..testutils import get_timeout
from .utils import blocking
from .provision import Quirk # NOQA: F401
provisioner = None
config = None
only_e2etest = False
e2etest_record = None
timeout = get_timeout(1.0)
def require_feature(feature_var, argname=None, *, multiple=False):
"""
:param feature_var: :xep:`30` feature ``var`` of the required feature
:type feature_var: :class:`str`
:param argname: Optional argument name to pass the :class:`FeatureInfo` to
:type argname: :class:`str` or :data:`None`
:param multiple: If true, all peers are returned instead of a random one.
:type multiple: :class:`bool`
Before running the function, it is tested that the feature specified by
`feature_var` is provided in the environment of the current provisioner. If
it is not, :class:`unittest.SkipTest` is raised to skip the test.
If the feature is available, the :class:`FeatureInfo` instance is passed to
the decorated function. If `argname` is :data:`None`, the feature info is
passed as additional positional argument. otherwise, it is passed as
keyword argument using the `argname`.
If `multiple` is true, all peers supporting the given feature are passed
in a set. Otherwise, only a random peer is returned.
This decorator can be used on test methods, but not on test classes. If you
want to skip all tests in a class, apply the decorator to the ``setUp``
method.
"""
if isinstance(feature_var, str):
feature_var = [feature_var]
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
global provisioner
if multiple:
arg = provisioner.get_feature_providers(feature_var)
has_provider = bool(arg)
else:
arg = provisioner.get_feature_provider(feature_var)
has_provider = arg is not None
if not has_provider:
raise unittest.SkipTest(
"provisioner does not provide a peer with "
"{!r}".format(feature_var)
)
if argname is None:
args = args+(arg,)
else:
kwargs[argname] = arg
return f(*args, **kwargs)
return wrapper
return decorator
def require_identity(category, type_, argname=None):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
global provisioner
arg = provisioner.get_identity_provider(category, type_)
has_provider = arg is not None
if not has_provider:
raise unittest.SkipTest(
"provisioner does not provide a peer with a "
"{!r} identity".format((category, type_))
)
if argname is None:
args = args+(arg,)
else:
kwargs[argname] = arg
return f(*args, **kwargs)
return wrapper
return decorator
def require_feature_subset(feature_vars, required_subset=[]):
required_subset = set(required_subset)
feature_vars = set(feature_vars) | required_subset
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
global provisioner
jid, subset = provisioner.get_feature_subset_provider(
feature_vars,
required_subset
)
if jid is None:
raise unittest.SkipTest(
"no peer could provide a subset of {!r} with at least "
"{!r}".format(
feature_vars,
required_subset,
)
)
return f(*(args+(jid, feature_vars)),
**kwargs)
return wrapper
return decorator
def require_pep(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
global provisioner
if not provisioner.has_pep():
raise unittest.SkipTest(
"the provisioned account does not support PEP",
)
return f(*args, **kwargs)
return wrapper
def skip_with_quirk(quirk):
"""
:param quirk: The quirk to skip on
:type quirk: :class:`Quirks`
If the provisioner indicates that the environment has the given `quirk`,
the test is skipped.
This decorator can be used on test methods, but not on test classes. If you
want to skip all tests in a class, apply the decorator to the ``setUp``
method.
"""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
global provisioner
if provisioner.has_quirk(quirk):
raise unittest.SkipTest(
"provisioner has quirk {!r}".format(quirk)
)
return f(*args, **kwargs)
return wrapper
return decorator
def blocking_with_timeout(timeout):
"""
The decorated coroutine function is run using the
:meth:`~asyncio.AbstractEventLoop.run_until_complete` method of the current
(at the time of call) event loop.
If the execution takes longer than `timeout` seconds,
:class:`asyncio.TimeoutError` is raised.
The decorated function behaves like a normal function and is not a
coroutine function.
This decorator must be applied to a coroutine function (or method).
"""
def decorator(f):
@blocking
@functools.wraps(f)
async def wrapper(*args, **kwargs):
return await asyncio.wait_for(f(*args, **kwargs), timeout)
return wrapper
return decorator
def blocking_timed(f):
"""
Like :func:`blocking_with_timeout`, the decorated coroutine function is
executed using :meth:`asyncio.AbstractEventLoop.run_until_complete` with a
timeout, but the timeout is configured in the end-to-end test configuration
(see :ref:`dg-end-to-end-tests`).
This is the recommended decorator for any test function or method, to
prevent the tests from hanging when anythin goes wrong. The timeout is
under control of the provisioner configuration, which means that it can be
adapted to different setups (for example, running against an XMPP server in
the internet will be slower than if it runs on localhost).
The decorated function behaves like a normal function and is not a
coroutine function.
This decorator must be applied to a coroutine function (or method).
"""
@blocking
@functools.wraps(f)
async def wrapper(*args, **kwargs):
global timeout
await asyncio.wait_for(f(*args, **kwargs), timeout)
return wrapper
@blocking
async def setup_package():
global provisioner, config, timeout
if config is None:
return
timeout = config.getfloat("global", "timeout", fallback=timeout)
provisioner_name = config.get("global", "provisioner")
module_path, class_name = provisioner_name.rsplit(".", 1)
mod = importlib.import_module(module_path)
cls_ = getattr(mod, class_name)
section = config[provisioner_name]
provisioner = cls_()
provisioner.configure(section)
await provisioner.initialise()
def teardown_package():
global provisioner, config
if config is None:
return
loop = asyncio.get_event_loop()
loop.run_until_complete(provisioner.finalise())
loop.close()
class TestCase(unittest.TestCase):
"""
A subclass of :class:`unittest.TestCase` for end-to-end test cases.
This subclass provides a single additional attribute:
.. autoattribute:: provisioner
"""
__unittest_skip__ = True
__unittest_skip_why__ = "this is not the aioxmpp test runner"
@property
def provisioner(self):
"""
This is the configured :class:`.provision.Provisioner` instance.
If no provisioner is configured (for example because the e2etest nose
plugin is not loaded), this reads as :data:`None`.
.. note::
Under nosetests and the vanilla unittest runner, tests inheriting
from :class:`TestCase` are automatically skipped if
:attr:`provisioner` is :data:`None`.
"""
global provisioner
return provisioner
def pytest_load_initial_conftests(early_config, parser, args):
parser.addoption(
"--e2etest-config",
dest="aioxmpp_e2e_config",
default=".local/e2etest.ini",
metavar="FILE",
help="Configuration file for end-to-end tests "
"(default: .local/e2etest.ini)",
)
parser.addoption(
"--e2etest-record",
dest="aioxmpp_e2e_record",
metavar="FILE",
default=None,
help="A file to write a transcript to"
)
parser.addoption(
"--e2etest-only",
dest="aioxmpp_e2e_only",
action="store_true",
default=False,
help="If set, only E2E tests will be executed."
)
def pytest_configure(config):
config.addinivalue_line("markers", "aioxmpp_e2etest: end-to-end test")
def pytest_cmdline_main(config):
return _pytest_cmdline_main_impl(config)
def _pytest_cmdline_main_impl(pytest_config):
global config, only_e2etest, e2etest_record
config = configparser.ConfigParser()
with open(pytest_config.option.aioxmpp_e2e_config, "r") as f:
config.read_file(f)
e2etest_record = pytest_config.option.aioxmpp_e2e_record
only_e2etest = pytest_config.option.aioxmpp_e2e_only
TestCase.__unittest_skip__ = False
def pytest_sessionstart(session):
setup_package()
def pytest_sessionfinish(session):
teardown_package()
@pytest.hookimpl(hookwrapper=True)
def pytest_pycollect_makeitem(collector, name, obj):
global config, only_e2etest
outcome = yield
item = outcome.get_result()
if isinstance(obj, type) and issubclass(obj, TestCase):
if config is None:
item.add_marker(pytest.mark.skip("e2e tests not enabled"))
else:
item.add_marker("aioxmpp_e2etest")
elif isinstance(obj, type) and issubclass(obj, unittest.TestCase):
if only_e2etest:
item.add_marker(pytest.mark.skip("only e2e tests enabled"))
def pytest_runtest_setup(item):
global provisioner, e2etest_record
if item.get_closest_marker("aioxmpp_e2etest") is not None:
blocking(provisioner.setup)()
def pytest_runtest_call(item):
if e2etest_record:
handler = logging.FileHandler(
e2etest_record, "w",
)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(name)s: %(levelname)s: %(message)s",
style="%"
)
handler.setFormatter(formatter)
logger = logging.getLogger("aioxmpp.e2etest.provision")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
def pytest_runtest_teardown(item):
global provisioner
if item.get_closest_marker("aioxmpp_e2etest") is not None:
blocking(provisioner.teardown)()
@@ -0,0 +1,29 @@
########################################################################
# File name: __main__.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/>.
#
########################################################################
import os
import pathlib
import sys
os.chdir(str(pathlib.Path(__file__).parent.parent.parent))
os.execv(
sys.executable,
[sys.executable, "-m", "pytest", "-p", "aioxmpp.e2etest"] + sys.argv[1:],
)
@@ -0,0 +1,810 @@
########################################################################
# File name: provision.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/>.
#
########################################################################
import abc
import ast
import asyncio
import base64
import enum
import fnmatch
import json
import logging
import random
import unittest
import aioxmpp
import aioxmpp.disco
import aioxmpp.security_layer
import aioxmpp.connector
_logger = logging.getLogger(__name__)
_rng = random.SystemRandom()
class Quirk(enum.Enum):
"""
Enumeration of implementation quirks.
Each enumeration member represents a quirk of an implementation. A quirk is
a behaviour of an implementation which does not directly violate standards,
but which is unfortunate in a way that it disables some features of
:mod:`aioxmpp`.
One example of such a quirk is the rewriting of message stanza IDs which
some MUC implementations do when reflecting the messages. This breaks the
stanza tracking of :meth:`aioxmpp.muc.Room.send_tracked_message`.
The following quirks are defined:
.. attribute:: MUC_REWRITES_MESSAGE_ID
:annotation: https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-id-rewrite
This quirk must be configured when the environment the provisioner
provides rewrites the message IDs when they are reflected by the MUC
implementation.
The quirk does not need to be set if the environment does not provide a
MUC implementation at all.
.. attribute:: PUBSUB_GET_ITEMS_BY_ID_BROKEN
:annotation: https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-pubsub-get-multiple-by-id
Indicates that the "Get Items by Id" operation in the PubSub service used
is broken when more than one item is requested.
""" # NOQA: E501
MUC_REWRITES_MESSAGE_ID = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-id-rewrite"
NO_ADHOC_PING = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#no-adhoc-ping"
MUC_NO_333 = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-no-333"
BROKEN_MUC = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-muc"
PUBSUB_GET_MULTIPLE_ITEMS_BY_ID_BROKEN = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-pubsub-get-multiple-by-id" # NOQA: E501
NO_PRIVATE_XML = \
"https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#no-xep-0049"
def fix_quirk_str(s):
if s.startswith("#"):
return "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks" + s
return s
def configure_tls_config(section):
"""
Generate keyword arguments for use with :meth:`.security_layer.make` from
the configuration which control the TLS behaviour of the security layer.
:param section: Configuration section to work on.
:return: Keyword arguments for :meth:`.security_layer.make`
:rtype: :class:`dict`
The generated keyword arguments are ``pin_type``, ``pin_store`` and
``no_verify``. The options in the config file have the same names and the
semantics are the following:
``pin_store`` and ``pin_type`` can be used to configure certificate
pinning, in case the server you want to test against does not have a
certificate which passes the default OpenSSL PKIX tests.
If set, ``pin_store`` must point to a JSON file, which consists of a single
object mapping host names to arrays of strings containing the base64
representation of what is being pinned. This is determined by ``pin_type``,
which can be ``0`` for Public Key pinning and ``1`` for Certificate
pinning.
There is also the ``no_verify`` option, which, if set to true, will disable
certificate verification altogether. This does not much harm if you are
testing against localhost anyways and saves the configuration nuisance for
certificate pinning. ``no_verfiy`` takes precedence over ``pin_store`` and
``pin_type``.
"""
no_verify = section.getboolean(
"no_verify",
fallback=False
)
if not no_verify and "pin_store" in section:
with open(section.get("pin_store")) as f:
pin_store = json.load(f)
pin_type = aioxmpp.security_layer.PinType(
section.getint("pin_type", fallback=0)
)
else:
pin_store = None
pin_type = None
return {
"pin_store": pin_store,
"pin_type": pin_type,
"no_verify": no_verify,
}
def configure_quirks(section):
"""
Generate a set of :class:`.Quirk` enum members from the given configuration
section.
:param section: Configuration section to work on.
:return: Set of :class:`.Quirk` members
This parses the configuration key ``quirks`` as a python literal (see
:func:`ast.literal_eval`). It expects a list of strings as a result.
The strings are interpreted as :class:`.Quirk` enum values. If a string
starts with ``#``, it is prefixed with
``https://zombofant.net/xmlns/aioxmpp/e2etest/quirks`` for easier manual
writing of the configuration. See :class:`.Quirk` for the currently defined
quirks.
"""
quirks = ast.literal_eval(section.get("quirks", fallback="[]"))
if isinstance(quirks, (str, dict)):
raise ValueError("incorrect type for quirks setting")
return set(map(Quirk, map(fix_quirk_str, quirks)))
def configure_blockmap(section):
blockmap_raw = ast.literal_eval(section.get("block_features",
fallback="{}"))
return {
aioxmpp.JID.fromstr(entity): features
for entity, features in blockmap_raw.items()
}
def _is_feature_blocked(peer, feature, blockmap):
return any(
fnmatch.fnmatch(feature, item)
for item in blockmap.get(peer, [])
)
async def discover_server_features(disco, peer, recurse_into_items=True,
blockmap={}):
"""
Use :xep:`30` service discovery to discover features supported by the
server.
:param disco: Service discovery client which can query the `peer` server.
:type disco: :class:`aioxmpp.DiscoClient`
:param peer: The JID of the server to query
:type peer: :class:`~aioxmpp.JID`
:param recurse_into_items: If set to true, the :xep:`30` items exposed by
the server will also be queried for their
features. Only one level of recursion is
performed.
:return: A mapping which maps :xep:`30` feature vars to the JIDs at which
the service is provided.
This uses :xep:`30` service discovery to obtain a set of features supported
at `peer`. The set of features is returned as a mapping which maps the
``var`` values of the features to the JID at which they were discovered.
If `recurse_into_items` is true, a :xep:`30` items query is run against
`peer`. For each JID discovered that way, :func:`discover_server_features`
is re-invoked (with `recurse_into_items` set to false). The resulting
mappings are merged with the mapping obtained from querying the features of
`peer` (existing entries are *not* overridden -- so `peer` takes
precedence).
"""
server_info = await disco.query_info(peer)
all_features = {
feature: [peer]
for feature in server_info.features
if not _is_feature_blocked(peer, feature, blockmap)
}
if recurse_into_items:
server_items = await disco.query_items(peer)
features_list = await asyncio.gather(
*(
discover_server_features(
disco,
item.jid,
recurse_into_items=False,
)
for item in server_items.items
if item.jid is not None and item.node is None
)
)
for features in features_list:
for feature, providers in features.items():
all_features.setdefault(feature, []).extend(providers)
return all_features
async def discover_server_identities(disco, peer, recurse_into_items=True):
"""
Use :xep:`30` service discovery to discover identities provided by the
server.
:param disco: Service discovery client which can query the `peer` server.
:type disco: :class:`aioxmpp.DiscoClient`
:param peer: The JID of the server to query
:type peer: :class:`~aioxmpp.JID`
:param recurse_into_items: If set to true, the :xep:`30` items exposed by
the server will also be queried for their
identities. Only one level of recursion is
performed.
:return: A mapping which maps :xep:`30` (category, type) tuples to the
JIDs at which the identity is provided.
This uses :xep:`30` service discovery to obtain a set of identities offered
at `peer`. The set of identities is returned as a mapping which maps the
``(category, type)`` tuples of the identities to the JID at which they were
discovered.
If `recurse_into_items` is true, a :xep:`30` items query is run against
`peer`. For each JID discovered that way,
:func:`discover_server_identities` is re-invoked (with `recurse_into_items`
set to false). The resulting mappings are merged with the mapping obtained
from querying the identities of `peer` (existing entries are *not*
overridden -- so `peer` takes precedence).
"""
server_info = await disco.query_info(peer)
all_identities = {
(identity.category, identity.type_): [peer]
for identity in server_info.identities
}
if recurse_into_items:
server_items = await disco.query_items(peer)
identities_list = await asyncio.gather(
*(
discover_server_identities(
disco,
item.jid,
recurse_into_items=False,
)
for item in server_items.items
if item.jid is not None and item.node is None
)
)
for identities in identities_list:
for identity, providers in identities.items():
all_identities.setdefault(identity, []).extend(providers)
return all_identities
class Provisioner(metaclass=abc.ABCMeta):
"""
Base class for provisioners.
Provisioners are responsible for providing test cases with XMPP accounts
and client objects connected to these accounts, as well as information
about the environment the accounts live in.
A provisioner must implement the following methods:
.. automethod:: _make_client
.. automethod:: configure
The following methods are the API used by test cases:
.. automethod:: get_connected_client
.. automethod:: get_feature_provider
.. automethod:: get_identity_provider
.. automethod:: has_quirk
These methods can be used by provisioners to perform plumbing tasks, such
as shutting down clients or deleting accounts:
.. automethod:: initialise
.. automethod:: finalise
.. automethod:: setup
.. automethod:: teardown
"""
def __init__(self, logger=_logger):
super().__init__()
self._accounts_to_dispose = []
self._featuremap = {}
self._identitymap = {}
self._account_info = None
self._logger = logger
self.__counter = 0
@abc.abstractmethod
async def _make_client(self, logger):
"""
:param logger: The logger to pass to the client.
:return: Client with a fresh account.
Construct a new :class:`aioxmpp.PresenceManagedClient` connected to a
new account. This method must be re-implemented by subclasses.
"""
async def get_connected_client(self, presence=aioxmpp.PresenceState(True), *,
services=[], prepare=None):
"""
Return a connected client to a unique XMPP account.
:param presence: initial presence to emit
:type presence: :class:`aioxmpp.PresenceState`
:param prepare: a coroutine run after the services
are summoned but before the client connects.
:type prepare: coroutine receiving the client
as argument
:raise OSError: if the connection failed
:raise RuntimeError: if a client could not be provisioned due to
resource constraints
:return: Connected presence managed client
:rtype: :class:`aioxmpp.PresenceManagedClient`
Each account used by the clients returned from this method is unique;
all clients are guaranteed to have different bare JIDs.
The clients and accounts are cleaned up after the tear down of the test
runs. Some provisioners may have a limit on the number of accounts
which can be used in the same test.
Clients obtained from this function are cleaned up automatically on
tear down of the test. The clients are stopped and the accounts
deleted or cleared, so that each test starts with a fully fresh state.
A coroutine may be passed as `prepare` argument. It is called
with the client as the single argument after all services in
`services` have been summoned but before the client connects,
this is for example useful to connect signals that fire early
in the connection process.
"""
id_ = self.__counter
self.__counter += 1
self._logger.debug("obtaining client%d from %r", id_, self)
logger = self._logger.getChild("client{}".format(id_))
client = await self._make_client(logger)
for service in services:
client.summon(service)
if prepare is not None:
await prepare(client)
cm = client.connected(presence=presence)
await cm.__aenter__()
self._accounts_to_dispose.append(cm)
return client
def get_feature_providers(self, feature_nses):
"""
:param feature_ns: Namespace URIs to find a provider for
:type feature_ns: iterable of :class:`str`
:return: JIDs of the entities providing all features
:rtype: :class:`set` of :class:`aioxmpp.JID`
If there is no entity supporting all requested features, the empty set
is returned.
"""
providers = set()
iterator = iter(feature_nses)
try:
first_ns = next(iterator)
except StopIteration:
return None
providers = set(self._featuremap.get(first_ns, []))
for feature_ns in iterator:
providers &= set(self._featuremap.get(feature_ns, []))
return providers
def get_feature_provider(self, feature_nses):
"""
:param feature_ns: Namespace URIs to find a provider for
:type feature_ns: iterable of :class:`str`
:return: JID of the entity providing all features
:rtype: :class:`aioxmpp.JID`
If there is no entity supporting all requested features, :data:`None`
is returned.
"""
providers = self.get_feature_providers(feature_nses)
if not providers:
return None
return next(iter(providers))
def get_identity_provider(self, category, type_):
return next(iter(self._identitymap.get((category, type_), [])))
def get_feature_subset_provider(self, feature_nses, required_subset):
required_subset = set(required_subset)
candidates = {}
for feature_ns in feature_nses:
providers = self._featuremap.get(feature_ns, [])
for provider in providers:
candidates.setdefault(provider, set()).add(feature_ns)
candidates = sorted(
(
(provider, features)
for provider, features in candidates.items()
if features & required_subset == required_subset
),
key=lambda x: (len(x[1]))
)
try:
return candidates.pop()
except IndexError:
return None, None
def has_quirk(self, quirk):
"""
:param quirk: Quirk to check for
:type quirk: :class:`Quirk`
:return: true if the environment has the given quirk
"""
return quirk in self._quirks
def has_pep(self):
"""
:return: true if the account has PEP support, false otherwise.
"""
if not self._account_info:
return False
return any(ident.category == "pubsub" and ident.type_ == "pep"
for ident in self._account_info.identities)
@abc.abstractmethod
def configure(self, section):
"""
Read the configuration and set up the provisioner.
:param section: mapping of config keys to values
Subclasses will implement this to configure their account setup and
servers to use.
.. seealso::
:func:`configure_tls_config`
for a function which extracts TLS-related arguments for
:func:`aioxmpp.security_layer.make`
:func:`configure_quirks`
for a function which extracts a set of :class:`.Quirk`
enumeration members from the configuration
:func:`configure_blockmap`
for a function which extracts a mapping which allows to block
features from specific hosts
"""
async def initialise(self):
"""
Called once on test framework startup.
Subclasses may run service discovery code here to detect features of
the environment they are connected to.
.. seealso::
:func:`discover_server_features`
for a function which uses :xep:`30` service discovery to find
features.
"""
async def finalise(self):
"""
Called once on test framework shutdown (timeout of 10 seconds applies).
"""
async def setup(self):
"""
Called before each test run.
"""
async def teardown(self):
"""
Called after each test run.
The default implementation cleans up the clients obtained from
:meth:`get_connected_client`.
"""
futures = []
for cm in self._accounts_to_dispose:
futures.append(asyncio.ensure_future(
cm.__aexit__(None, None, None)
))
self._accounts_to_dispose.clear()
self._logger.debug("waiting for %d accounts to shut down",
len(futures))
await asyncio.gather(
*futures,
return_exceptions=True
)
class _AutoConfiguredProvisioner(Provisioner):
def configure(self, section):
super().configure(section)
self._blockmap = configure_blockmap(section)
async def initialise(self):
self._logger.debug("auto-configuring provisioner %s", self)
client = await self.get_connected_client()
disco = client.summon(aioxmpp.DiscoClient)
self._featuremap.update(await discover_server_features(
disco,
self._domain,
blockmap=self._blockmap,
))
self._identitymap.update(await discover_server_identities(
disco,
self._domain,
))
self._logger.debug("found %d features", len(self._featuremap))
if self._logger.isEnabledFor(logging.DEBUG):
for feature, providers in self._featuremap.items():
self._logger.debug(
"%s provided by %s",
feature,
", ".join(sorted(map(str, providers)))
)
self._account_info = await disco.query_info(None)
# clean up state
del client
await self.teardown()
class AnonymousProvisioner(_AutoConfiguredProvisioner):
"""
This provisioner uses SASL ANONYMOUS to obtain accounts.
It is dead-simple to configure: it needs a host to connect to, and
optionally some TLS and quirks configuration. The host is specified as
configuration key ``host``, TLS can be configured as documented in
:func:`configure_tls_config` and quirks are set as described in
:func:`configure_quirks`. A configuration for a locally running Prosody
instance might look like this:
.. code-block:: ini
[aioxmpp.e2etest.provision.AnonymousProvisioner]
host=localhost
no_verify=true
quirks=[]
The server configured in ``host`` must support SASL ANONYMOUS and must
allow communication between the clients connected that way. It may provide
PubSub and/or MUC services, which will be auto-discovered if they are
provided in the :xep:`30` items of the server.
"""
def configure(self, section):
super().configure(section)
self.__host = section.get("host")
self._domain = aioxmpp.JID.fromstr(section.get(
"domain",
self.__host
))
self.__port = section.getint("port")
self.__security_layer = aioxmpp.make_security_layer(
None,
anonymous="",
**configure_tls_config(
section
)
)
self._quirks = configure_quirks(section)
async def _make_client(self, logger):
override_peer = []
if self.__port is not None:
override_peer.append(
(self.__host, self.__port,
aioxmpp.connector.STARTTLSConnector())
)
return aioxmpp.PresenceManagedClient(
self._domain,
self.__security_layer,
override_peer=override_peer,
logger=logger,
)
class AnyProvisioner(_AutoConfiguredProvisioner):
"""
This provisioner randomly generates usernames and uses a hardcoded password
to authenticate with the XMPP server.
This is for use with ``mod_auth_any`` of prosody.
It is dead-simple to configure: it needs a host to connect to, and
optionally some TLS and quirks configuration. The host is specified as
configuration key ``host``, TLS can be configured as documented in
:func:`configure_tls_config` and quirks are set as described in
:func:`configure_quirks`. A configuration for a locally running Prosody
instance might look like this:
.. code-block:: ini
[aioxmpp.e2etest.provision.AnyProvisioner]
host=localhost
no_verify=true
quirks=[]
The server configured in ``host`` must allow authentication with any
username/password pair and allow communication between the clients
connected that way. It may provide PubSub and/or MUC services, which will
be auto-discovered if they are provided in the :xep:`30` items of the
server.
"""
def configure(self, section):
super().configure(section)
self.__host = section.get("host")
self._domain = aioxmpp.JID.fromstr(section.get(
"domain",
self.__host
))
self.__port = section.getint("port")
self.__security_layer = aioxmpp.make_security_layer(
"foobar2342", # password is irrelevant, but must be given.
**configure_tls_config(
section
)
)
self._quirks = configure_quirks(section)
self.__username_rng = random.Random()
self.__username_rng.seed(_rng.getrandbits(256))
async def _make_client(self, logger):
override_peer = []
if self.__port is not None:
override_peer.append(
(self.__host, self.__port,
aioxmpp.connector.STARTTLSConnector())
)
user = base64.b32encode(
self.__username_rng.getrandbits(128).to_bytes(128//8, 'little')
).decode("ascii").rstrip("=")
user_jid = self._domain.replace(localpart=user)
return aioxmpp.PresenceManagedClient(
user_jid,
self.__security_layer,
override_peer=override_peer,
logger=logger,
)
class StaticPasswordProvisioner(_AutoConfiguredProvisioner):
"""
This provisioner expects a list of username/password pairs to authenticate
against the tested server.
This is for use with servers which support neither SASL ANONYMOUS nor
a ``mod_auth_any`` equivalent.
The configuration of this provisioner is slightly unwieldy since we do
not want to add a dependency to a more sane configuration file format. Here
is an example on how to configure a provisioner with two accounts:
.. code-block:: ini
[aioxmpp.e2etest.provision.StaticPasswordProvisioner]
host=localhost
accounts=[("user1", "password1"), ("user2", "password2")]
skip_on_too_few_accounts=false
All accounts need to have exactly the same privileges on the server. The
first account will be used to auto-discover any features offered by the
test environment.
If `skip_on_too_few_accounts` is set to true (the default is false), tests
will be skipped if the provisioner runs out of accounts instead of failing.
"""
def _load_accounts(self, cfg):
result = []
for username, password in ast.literal_eval(cfg):
result.append((
aioxmpp.JID(localpart=username, domain=self._domain.domain,
resource=None),
aioxmpp.make_security_layer(password, **self.__tls_config)
))
return result
def configure(self, section):
super().configure(section)
self.__host = section.get("host")
self._domain = aioxmpp.JID.fromstr(section.get(
"domain",
self.__host
))
self.__port = section.getint("port")
self.__tls_config = configure_tls_config(section)
self.__accounts = self._load_accounts(section.get("accounts"))
if len(self.__accounts) == 0:
raise RuntimeError(
"at least one account needs to be configured in the "
"StaticPasswordProvisioner section"
)
self.__nused_accounts = 0
self._quirks = configure_quirks(section)
self.__username_rng = random.Random()
self.__skip_on_too_few_accounts = section.getboolean(
"skip_on_too_few_accounts",
fallback=False,
)
async def _make_client(self, logger):
override_peer = []
if self.__port is not None:
override_peer.append(
(self.__host, self.__port,
aioxmpp.connector.STARTTLSConnector())
)
next_account = self.__nused_accounts
try:
address, security_layer = self.__accounts[next_account]
except IndexError:
err = (
"not enough accounts; needed at least one more account "
"after already using {} accounts".format(next_account)
)
if self.__skip_on_too_few_accounts:
raise unittest.SkipTest(err)
raise RuntimeError(err)
self.__nused_accounts += 1
return aioxmpp.PresenceManagedClient(
address,
security_layer,
override_peer=override_peer,
logger=logger,
)
async def teardown(self):
await super().teardown()
self.__nused_accounts = 0
@@ -0,0 +1,42 @@
########################################################################
# File name: utils.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/>.
#
########################################################################
import asyncio
import functools
def blocking(f):
"""
The decorated coroutine function is run using the
:meth:`~asyncio.AbstractEventLoop.run_until_complete` method of the current
(at the time of call) event loop.
The decorated function behaves like a normal function and is not a
coroutine function.
This decorator must be applied to a coroutine function (or method).
"""
@functools.wraps(f)
def wrapped(*args, **kwargs):
loop = asyncio.get_event_loop()
return loop.run_until_complete(f(*args, **kwargs))
return wrapped
@@ -0,0 +1,63 @@
########################################################################
# File name: __init__.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.entitycaps` --- Entity Capabilities support (:xep:`390`, :xep:`0115`)
####################################################################################
This module provides support for :xep:`XEP-0115 (Entity Capabilities) <0115>`
and :xep:`XEP-0390 (Entity Capabilities 2.0) <0390>`. To use it,
:meth:`.Client.summon` the :class:`aioxmpp.EntityCapsService` on a
:class:`~.Client`. See the service documentation for more information.
.. versionadded:: 0.5
.. versionchanged:: 0.9
Support for :xep:`390` was added.
Service
=======
.. currentmodule:: aioxmpp
.. autoclass:: EntityCapsService
.. currentmodule:: aioxmpp.entitycaps
.. class:: Service
Alias of :class:`.EntityCapsService`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
.. autoclass:: Cache
.. currentmodule:: aioxmpp.entitycaps.xso
""" # NOQA: E501
from .service import EntityCapsService, Cache # NOQA: F401
from . import xso # NOQA: F401
Service = EntityCapsService
@@ -0,0 +1,175 @@
########################################################################
# File name: caps115.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/>.
#
########################################################################
import base64
import collections
import hashlib
import pathlib
import urllib.parse
from xml.sax.saxutils import escape
from .common import AbstractKey, AbstractImplementation
from . import xso as caps_xso
def build_identities_string(identities):
identities = [
b"/".join([
escape(identity.category).encode("utf-8"),
escape(identity.type_).encode("utf-8"),
escape(str(identity.lang or "")).encode("utf-8"),
escape(identity.name or "").encode("utf-8"),
])
for identity in identities
]
if len(set(identities)) != len(identities):
raise ValueError("duplicate identity")
identities.sort()
identities.append(b"")
return b"<".join(identities)
def build_features_string(features):
features = list(escape(feature).encode("utf-8") for feature in features)
if len(set(features)) != len(features):
raise ValueError("duplicate feature")
features.sort()
features.append(b"")
return b"<".join(features)
def build_forms_string(forms):
types = set()
forms_list = []
for form in forms:
try:
form_types = set(
value
for field in form.fields.filter(attrs={"var": "FORM_TYPE"})
for value in field.values
)
except KeyError:
continue
if len(form_types) > 1:
raise ValueError("form with multiple types")
elif not form_types:
continue
type_ = escape(next(iter(form_types))).encode("utf-8")
if type_ in types:
raise ValueError("multiple forms of type {!r}".format(type_))
types.add(type_)
forms_list.append((type_, form))
forms_list.sort()
parts = []
for type_, form in forms_list:
parts.append(type_)
field_list = sorted(
(
(escape(field.var).encode("utf-8"), field.values)
for field in form.fields
if field.var != "FORM_TYPE"
),
key=lambda x: x[0]
)
for var, values in field_list:
parts.append(var)
parts.extend(sorted(
escape(value).encode("utf-8") for value in values
))
parts.append(b"")
return b"<".join(parts)
def hash_query(query, algo):
hashimpl = hashlib.new(algo)
hashimpl.update(
build_identities_string(query.identities)
)
hashimpl.update(
build_features_string(query.features)
)
hashimpl.update(
build_forms_string(query.exts)
)
return base64.b64encode(hashimpl.digest()).decode("ascii")
Key = collections.namedtuple("Key", ["algo", "node"])
class Key(Key, AbstractKey):
@property
def path(self):
quoted = urllib.parse.quote(self.node, safe="")
return (pathlib.Path("hashes") /
"{}_{}.xml".format(self.algo, quoted))
@property
def ver(self):
return self.node.rsplit("#", 1)[1]
def verify(self, query_response):
digest_b64 = hash_query(query_response, self.algo.replace("-", ""))
return self.ver == digest_b64
class Implementation(AbstractImplementation):
def __init__(self, node, **kwargs):
super().__init__(**kwargs)
self.__node = node
def extract_keys(self, obj):
caps = obj.xep0115_caps
if caps is None or caps.hash_ is None:
return
yield Key(caps.hash_, "{}#{}".format(caps.node, caps.ver))
def put_keys(self, keys, presence):
key, = keys
presence.xep0115_caps = caps_xso.Caps115(
self.__node,
key.ver,
key.algo,
)
def calculate_keys(self, query_response):
yield Key(
"sha-1",
"{}#{}".format(
self.__node,
hash_query(query_response, "sha1"),
)
)

Some files were not shown because too many files have changed in this diff Show More