v1.3.5
This commit is contained in:
@@ -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 isn’t 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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, it’s 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)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
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()])
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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 don’t 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)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
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:],
|
||||
)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user